File size: 162,024 Bytes
5448d8b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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
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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
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
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
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
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
1443
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
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
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
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
3542
3543
3544
3545
3546
3547
3548
3549
3550
3551
3552
3553
3554
3555
3556
3557
3558
3559
3560
3561
3562
/**

 * OpenCode plugin for the OmniRoute AI Gateway.

 *

 * Implements the official `@opencode-ai/plugin` Plugin contract (auth +

 * provider + config hooks) to drive a running OmniRoute instance from

 * OpenCode without hand-curated `provider.<id>.models` blocks in

 * opencode.json[c]:

 *

 *   - `auth`     β€” registers `/connect <providerId>` flow (API key prompt)

 *   - `provider` β€” dynamic `/v1/models` fetch with TTL cache, capabilities

 *                  pass-through (OmniRoute is the source of truth β€” no

 *                  client-side variant synthesis)

 *   - `config`   β€” backward-compat shim for OC versions that predate the

 *                  `provider.models` hook (≀ 1.14.48)

 *

 * Two ways to consume the plugin:

 *

 *  1. Single-instance (default `providerId: "omniroute"`):

 *

 *     ```json

 *     {

 *       "$schema": "https://opencode.ai/config.json",

 *       "plugin": ["@omniroute/opencode-plugin"]

 *     }

 *     ```

 *

 *  2. Multi-instance via plugin options (prod + preprod side by side):

 *

 *     ```json

 *     {

 *       "$schema": "https://opencode.ai/config.json",

 *       "plugin": [

 *         ["@omniroute/opencode-plugin", { "providerId": "omniroute" }],

 *         ["@omniroute/opencode-plugin", { "providerId": "omniroute-preprod" }]

 *       ]

 *     }

 *     ```

 *

 * Then `opencode connect <providerId>` to provision the API key per instance.

 *

 * Companion library: `@omniroute/opencode-provider` (build-time config generator)

 * remains supported for users who can't run plugins (CI, scripted scaffolding).

 *

 * @see https://opencode.ai/docs/plugins for the OpenCode plugin contract.

 * @see https://github.com/diegosouzapw/OmniRoute for the AI Gateway.

 */

import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@opencode-ai/plugin";
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import { z } from "zod";

/**

 * Zod schema for plugin options accepted as the second element of the

 * `plugin: [name, opts]` tuple in opencode.json. Strict by design β€” unknown

 * keys are rejected so typos in opencode.json surface immediately at plugin

 * construction time instead of silently being dropped.

 *

 * Doc per field:

 *

 *  - `providerId`     OpenCode provider id this plugin instance binds to.

 *                     Multiple plugin instances coexist by giving each a

 *                     different `providerId` ("omniroute", "omniroute-preprod",

 *                     "omniroute-local"). Maps to `ProviderHook.id` and

 *                     `AuthHook.provider` in the @opencode-ai/plugin contract.

 *                     Default: "omniroute".

 *  - `displayName`    Label rendered in the OpenCode UI. Default derives

 *                     from providerId.

 *  - `modelCacheTtl`  `/v1/models` TTL cache duration in milliseconds.

 *                     Default: 300_000 (5 min).

 *  - `baseURL`        Override base URL for this OmniRoute instance. When

 *                     absent, the loader falls back to a credential-attached

 *                     baseURL set by `/connect`.

 */
/**

 * Optional feature toggles. Every field is opt-in/out per call; defaults

 * mirror the v0.1.0 behaviour so existing opencode.json files do not need

 * to change.

 *

 *  - `combos`               Discover `/api/combos` and surface them as

 *                           pseudo-models with LCD capabilities. Default true.

 *  - `enrichment`           Pull display names + pricing from

 *                           `/api/pricing/models` and overlay them onto the

 *                           ModelV2 entries derived from `/v1/models`. Solves

 *                           the "raw id in UI" complaint without client-side

 *                           heuristics. Default true.

 *  - `compressionMetadata`  Pull `/api/context/combos` so combo entries can

 *                           be tagged with their compression pipeline

 *                           (e.g. `rtk:standard β†’ caveman:full`). Off by

 *                           default β€” adds one network call per refresh and

 *                           the data is only useful for combo entries.

 *  - `geminiSanitization`   Strip `$schema`/`$ref`/`additionalProperties`

 *                           from `tools[].function.parameters` when the

 *                           model id contains "gemini". Default true.

 *  - `mcpAutoEmit`          Auto-write an `mcp.<providerId>` remote entry

 *                           into the OC config pointing at

 *                           `<baseURL>/api/mcp/stream` with the resolved

 *                           Bearer token. Default false β€” keeps opencode.json

 *                           in control unless explicitly opted in.

 *  - `mcpToken`             Optional separate Bearer token to use in the

 *                           auto-emitted MCP entry. Falls back to the

 *                           provider's API key (from auth.json) when unset.

 *                           Useful when a narrower-scoped MCP-only key is

 *                           preferred over the chat/inference key.

 *  - `fetchInterceptor`     Inject Authorization: Bearer + Content-Type on

 *                           every outbound request to baseURL. Default true.

 */
const featuresSchema = z
  .object({
    combos: z.boolean().optional(),
    enrichment: z.boolean().optional(),
    compressionMetadata: z.boolean().optional(),
    geminiSanitization: z.boolean().optional(),
    mcpAutoEmit: z.boolean().optional(),
    mcpToken: z.string().min(1).optional(),
    fetchInterceptor: z.boolean().optional(),
    usableOnly: z.boolean().optional(),
    diskCache: z.boolean().optional(),
    providerTag: z.boolean().optional(),
  })
  .strict();

const optionsSchema = z
  .object({
    providerId: z
      .string()
      .min(1)
      .regex(/^[a-z0-9-]+$/i, "providerId must be a slug")
      .optional(),
    displayName: z.string().min(1).optional(),
    modelCacheTtl: z.number().positive().optional(),
    baseURL: z.string().url().optional(),
    features: featuresSchema.optional(),
  })
  .strict();

/**

 * Plugin options shape β€” inferred directly from the Zod schema so the

 * validator and the static type can never drift. Replaces the standalone

 * interface previously declared here (T-02). Every consumer continues to

 * import `OmniRoutePluginOptions` as before; only the source of truth

 * shifted from a hand-written interface to `z.infer<typeof optionsSchema>`.

 */
export type OmniRoutePluginOptions = z.infer<typeof optionsSchema>;

export const OMNIROUTE_PROVIDER_KEY = "omniroute" as const;

export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const;

// Manual trim helpers avoid polynomial-regex CodeQL warnings on
// user-supplied baseURL strings (string.replace(/\/+$/, "")). The same
// behaviour, no backtracking.
function trimTrailingSlashes(value: string): string {
  let i = value.length;
  while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--;
  return i === value.length ? value : value.slice(0, i);
}
function trimTrailingDashes(value: string): string {
  let i = value.length;
  while (i > 0 && value.charCodeAt(i - 1) === 0x2d /* "-" */) i--;
  return i === value.length ? value : value.slice(0, i);
}
function trimLeadingDashes(value: string): string {
  let i = 0;
  while (i < value.length && value.charCodeAt(i) === 0x2d /* "-" */) i++;
  return i === 0 ? value : value.slice(i);
}

/**

 * Resolve effective options from the optional plugin-options object,

 * applying defaults. Centralises the providerId fallback so every hook

 * sees a consistent identifier.

 */
export function resolveOmniRoutePluginOptions(

  opts?: OmniRoutePluginOptions

): Required<Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl">> &
  Pick<OmniRoutePluginOptions, "baseURL" | "features"> {
  const providerId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY;
  const displayName =
    opts?.displayName ??
    (providerId === OMNIROUTE_PROVIDER_KEY ? "OmniRoute" : `OmniRoute (${providerId})`);
  const modelCacheTtl =
    typeof opts?.modelCacheTtl === "number" && opts.modelCacheTtl > 0
      ? opts.modelCacheTtl
      : DEFAULT_MODEL_CACHE_TTL_MS;
  return {
    providerId,
    displayName,
    modelCacheTtl,
    baseURL: opts?.baseURL,
    features: opts?.features,
  };
}

/**

 * Strict parse of raw plugin options (as received from opencode.json or a

 * direct factory call) into the validated `OmniRoutePluginOptions` shape.

 *

 *   - `null` / `undefined` β†’ `{}` (no opts is valid, defaults take over).

 *   - Unknown keys β†’ throws (strict schema catches typos in opencode.json).

 *   - Empty / malformed values (e.g. empty providerId, non-URL baseURL,

 *     negative modelCacheTtl) β†’ throws.

 *

 * Validation happens at plugin invocation time (inside `OmniRoutePlugin`),

 * NOT at module import β€” so a bad opencode.json fails the affected plugin

 * instance with an actionable message instead of crashing the whole TUI on

 * startup.

 *

 * Exported so callers and tests can validate options independent of the

 * full plugin factory invocation.

 */
export function parseOmniRoutePluginOptions(opts: unknown): OmniRoutePluginOptions {
  if (opts === null || opts === undefined) return {};
  const result = optionsSchema.safeParse(opts);
  if (!result.success) {
    const errs = result.error.issues
      .map((i) => {
        const path = i.path.length > 0 ? i.path.join(".") : "<root>";
        return `${path}: ${i.message}`;
      })
      .join("; ");
    throw new Error(`Invalid @omniroute/opencode-plugin options: ${errs}`);
  }
  return result.data;
}

/**

 * Internal coercion shim. Delegates to `parseOmniRoutePluginOptions` to keep

 * the public surface stable while routing all validation through the Zod

 * schema. Always returns an object (never undefined) so downstream hooks see

 * the same shape regardless of whether opencode.json passed `null`,

 * `undefined`, or an empty bag.

 */
function coercePluginOptions(opts?: PluginOptions): OmniRoutePluginOptions {
  return parseOmniRoutePluginOptions(opts);
}

/**

 * Build the AuthHook portion of the plugin for a given options bag. Exported

 * standalone so the auth contract can be unit-tested without faking the full

 * PluginInput / Hooks surface.

 *

 * Contract notes:

 *   - `provider` binds to `providerId` (NOT a hardcoded module constant β€” fixes

 *     the multi-instance bug in opencode-omniroute-auth@1.2.1 which pinned

 *     `OMNIROUTE_PROVIDER_ID = "omniroute"` at module scope).

 *   - `methods[0]` is the `api` flavor (no OAuth flow; OmniRoute issues bearer

 *     keys directly). Label includes the resolved displayName so multi-instance

 *     setups stay distinguishable in the OC TUI.

 *   - `methods[0].prompts` uses the official `{type:"text", key, message}`

 *     shape from `@opencode-ai/plugin@1.15.6`. The contract does NOT expose

 *     a `mask: true` flag on text prompts β€” the OC TUI is expected to handle

 *     credential masking by itself (per OC's `auth login` UX).

 *   - `loader` reads the stored credentials via `getAuth()` and projects them

 *     into the AI-SDK `openai-compatible` options shape (`apiKey`, `baseURL`).

 *     The fetch interceptor (`fetch`) is wired in T-04; left absent here so

 *     downstream code falls back to the SDK default fetch.

 *   - The loader rejects non-`api` auth flavors (oauth / wellknown) and empty

 *     keys by returning `{}` β€” OC then surfaces the `/connect` flow to the

 *     user instead of dispatching a request with bogus credentials.

 */
export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook {
  const { providerId, displayName, baseURL, features } = resolveOmniRoutePluginOptions(opts);
  // Both fetch-layer features default ON (parity with the rest of the plugin's
  // `features.X !== false` convention). Honoring them here lets users disable
  // the interceptor/sanitizer from opencode.json β€” previously these flags were
  // documented and schema-validated but silently ignored.
  const wantFetchInterceptor = (features ?? {}).fetchInterceptor !== false;
  const wantGeminiSanitization = (features ?? {}).geminiSanitization !== false;

  const hook: AuthHook = {
    provider: providerId,
    methods: [
      {
        type: "api",
        label: `${displayName} API Key`,
        prompts: [
          {
            type: "text",
            key: "apiKey",
            message: `OmniRoute API key (${providerId})`,
          },
        ],
      },
    ],
    loader: async (getAuth, _provider) => {
      const auth = await getAuth();
      if (
        auth &&
        typeof auth === "object" &&
        (auth as { type?: unknown }).type === "api" &&
        typeof (auth as { key?: unknown }).key === "string" &&
        (auth as { key: string }).key.length > 0
      ) {
        const apiKey = (auth as { key: string }).key;
        // baseURL resolution: plugin opts win, then a credential-attached
        // baseURL (some auth backends stash it alongside the key), else empty.
        // Re-cast through `unknown` first: Auth is a discriminated union
        // (api | oauth | wellknown) and TS refuses a direct narrowing to a
        // hypothetical `{ baseURL: string }` shape because WellKnownAuth has
        // no `baseURL`. We've already checked the runtime type via typeof so
        // the unknown-bridge is a safe assertion, not a lie.
        const authBaseURL = (auth as unknown as { baseURL?: unknown }).baseURL;
        const resolvedBaseURL = baseURL ?? (typeof authBaseURL === "string" ? authBaseURL : "");
        // Without a baseURL the interceptor can't tell which requests to
        // intercept (it would either gate-keep nothing or, worse, all
        // outbound traffic). Fall back to apiKey-only and let the SDK use
        // its default fetch. The /connect flow + plugin opts should make
        // this branch unreachable in practice.
        if (!resolvedBaseURL) {
          return { apiKey };
        }
        // Composition: sanitise Gemini tool schemas FIRST (T-06), then inject
        // Bearer (T-04). Both layers are pure with respect to the other's
        // concern (body vs headers) so order is logically free; wrapping the
        // pure body-transform around the header-injecting interceptor reads
        // cleaner and keeps T-06 testable in isolation against any inner fetch
        // (real or stub). Each layer is gated by its feature flag; when both
        // are disabled we fall back to the SDK's default fetch (apiKey only).
        let composedFetch: typeof fetch | undefined;
        if (wantFetchInterceptor) {
          composedFetch = createOmniRouteFetchInterceptor({
            apiKey,
            baseURL: resolvedBaseURL,
          });
        }
        if (wantGeminiSanitization) {
          composedFetch = createGeminiSanitizingFetch(composedFetch ?? fetch);
        }
        return composedFetch
          ? { apiKey, baseURL: resolvedBaseURL, fetch: composedFetch }
          : { apiKey, baseURL: resolvedBaseURL };
      }
      return {};
    },
  };

  return hook;
}

/**

 * Plugin factory. Returns the OpenCode Plugin object wired with the three

 * hooks. Concrete hook bodies land in subsequent tickets (T-03 provider.models,

 * T-04 fetch interceptor, T-06 Gemini sanitization, T-07 config backward-compat).

 *

 * Per `@opencode-ai/plugin@1.15.6`, the Plugin signature is

 * `(input: PluginInput, options?: PluginOptions) => Promise<Hooks>` β€” opts

 * arrive as the SECOND argument (from the `[name, opts]` tuple in

 * opencode.json), NOT as a closure binding. Multi-instance support follows

 * from each plugin tuple invoking the factory with its own opts.

 */
export const OmniRoutePlugin: Plugin = async (_input, options) => {
  const resolved = coercePluginOptions(options);
  // T-07: a single per-plugin-instance cache shared between the provider
  // hook (T-03/T-05) and the config-shim hook (T-07). On OC β‰₯1.14.49 both
  // hooks fire within the same Plugin invocation, so a shared cache keeps
  // /v1/models + /api/combos at exactly one round-trip per TTL refresh
  // instead of two. On OC ≀1.14.48 only the config hook runs; the cache
  // still works (single producer + single consumer through the same map).
  // Each `OmniRoutePlugin(...)` invocation gets its OWN cache via closure,
  // so prod + preprod side-by-side instances do NOT collide.
  const sharedCache: OmniRouteFetchCache = new Map();
  // Debug breadcrumb: confirm server() invocation + resolved options.
  // Useful when diagnosing "is the plugin even running" from OC logs.
  console.warn(
    `[omniroute-plugin] initialized providerId=${resolved.providerId} displayName="${resolved.displayName}" baseURL=${resolved.baseURL ?? "(from auth.json)"} modelCacheTtl=${resolved.modelCacheTtl}ms`
  );
  return {
    auth: createOmniRouteAuthHook(resolved),
    provider: createOmniRouteProviderHook(resolved, { cache: sharedCache }),
    config: createOmniRouteConfigHook(resolved, { cache: sharedCache }),
  };
};

/**

 * v1 plugin shape per OC plugin loader (`packages/opencode/src/plugin/shared.ts:readV1Plugin`).

 * OC checks the default export for an object with `{id, server}` shape FIRST.

 * If that fails it falls back to legacy `getLegacyPlugins` which walks every

 * named export and rejects any non-function value β€” our package has

 * constants (OMNIROUTE_PROVIDER_KEY, DEFAULT_MODEL_CACHE_TTL_MS) + types +

 * schemas as named exports, so the legacy path always fails for us.

 *

 * Using v1 shape skips the legacy walk entirely. The `id` field is the

 * plugin MODULE identifier (one per published package); per-instance

 * `providerId` still flows through `options.providerId` as before.

 */
const OmniRouteV1Plugin = {
  id: "@omniroute/opencode-plugin",
  server: OmniRoutePlugin,
};

export default OmniRouteV1Plugin;

// ────────────────────────────────────────────────────────────────────────────
// Provider hook (T-03) β€” /v1/models pass-through with TTL cache
// ────────────────────────────────────────────────────────────────────────────

/**

 * Raw shape of a `/v1/models` entry from OmniRoute. Captured verbatim from

 * the prod gateway response (sample at /tmp/prod-v1-models.json: 455 entries).

 * STRICT source-of-truth (OQ-3): every field that lands in ModelV2 traces

 * back to this shape β€” no client-side variant synthesis.

 */
export interface OmniRouteRawModelEntry {
  id: string;
  object?: string;
  owned_by?: string;
  root?: string | null;
  parent?: string | null;
  context_length?: number;
  max_input_tokens?: number;
  max_output_tokens?: number;
  input_modalities?: string[];
  output_modalities?: string[];
  capabilities?: {
    tool_calling?: boolean;
    reasoning?: boolean;
    vision?: boolean;
    thinking?: boolean;
    attachment?: boolean;
    structured_output?: boolean;
    temperature?: boolean;
  };
  release_date?: string;
  last_updated?: string;
  api_format?: string;
}

/**

 * Fetcher contract: returns the raw `/v1/models` entry list from a running

 * OmniRoute instance. Surfaced as a dependency so unit tests can inject a

 * stub without monkey-patching global `fetch`.

 *

 * Why we inline this instead of using `@omniroute/opencode-provider`'s

 * `fetchLiveModels`: the sibling helper returns a stripped `{id, name,

 * contextLength?}` shape (see opencode-provider/src/index.ts:480-569) that

 * drops the `capabilities` / `*_modalities` / `max_*_tokens` blocks T-03

 * needs for ModelV2 pass-through. Adopting the sibling here would force a

 * client-side re-fetch or re-introduce the synthesis we explicitly rejected

 * in OQ-3. A 30-line raw fetcher is cheaper than mutating the sibling's

 * stable v0.1.0 contract.

 */
export type OmniRouteModelsFetcher = (

  baseURL: string,

  apiKey: string,

  timeoutMs?: number

) => Promise<OmniRouteRawModelEntry[]>;

/**

 * Default fetcher: `GET <baseURL>/v1/models` with bearer auth + AbortController

 * timeout. Accepts both the `{object:"list", data:[…]}` envelope OmniRoute

 * emits today and a bare-array envelope (defensive β€” keeps the plugin

 * working if a future OmniRoute build trims the wrapper). Anything that

 * isn't an object with a string `id` is filtered out silently.

 */
export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
  baseURL,
  apiKey,
  timeoutMs = 10_000
) => {
  if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /v1/models");
  if (!baseURL) throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /v1/models");

  const trimmed = trimTrailingSlashes(baseURL);
  // Tolerate both `https://host` and `https://host/v1` forms β€” the gateway
  // exposes /v1/models either way; we just don't want a double `/v1/v1`.
  const url = /\/v\d+$/.test(trimmed) ? `${trimmed}/models` : `${trimmed}/v1/models`;

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const res = await fetch(url, {
      method: "GET",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        Accept: "application/json",
      },
      signal: controller.signal,
    });
    if (!res.ok) {
      throw new Error(
        `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}`
      );
    }
    const body = (await res.json()) as unknown;
    const rawList: unknown[] = Array.isArray(body)
      ? body
      : body && typeof body === "object" && Array.isArray((body as { data?: unknown }).data)
        ? ((body as { data: unknown[] }).data as unknown[])
        : [];
    const out: OmniRouteRawModelEntry[] = [];
    for (const r of rawList) {
      if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") {
        out.push(r as OmniRouteRawModelEntry);
      }
    }
    return out;
  } finally {
    clearTimeout(timer);
  }
};

/**

 * Map a raw `/v1/models` entry β†’ `ModelV2` (the type @opencode-ai/sdk/v2

 * exports as `Model`, re-exported by @opencode-ai/plugin as `ModelV2`).

 *

 * ModelV2 (as of @opencode-ai/sdk@v2 β€” see node_modules path

 * `@opencode-ai/sdk/dist/v2/gen/types.gen.d.ts:964-1043`) requires a much

 * richer shape than the T-03 spec's mapping table assumed. Concretely it

 * expects:

 *   - flat `id`, `name`, `providerID`, `api: {id,url,npm}`

 *   - nested `capabilities: { temperature, reasoning, attachment, toolcall,

 *     input:{text,audio,image,video,pdf}, output:{…}, interleaved }`

 *   - `cost: { input, output, cache:{read,write} }` (NOT optional)

 *   - `limit: { context, input?, output }`

 *   - `status: "alpha"|"beta"|"deprecated"|"active"`, `options:{}`, `headers:{}`

 *   - `release_date: string`

 *

 * Deviations from the T-03 spec (documented per ticket Β§2 "CRITICAL: Check

 * the actual ModelV2 type and adapt if field names differ"):

 *   1. Spec's flat `tool_call` / `reasoning` / `attachment` / `modalities`

 *      top-level fields don't exist in ModelV2 β€” folded into

 *      `capabilities.{toolcall, reasoning, attachment, input.*, output.*}`.

 *   2. `cost: undefined` is illegal (cost is required). OmniRoute doesn't

 *      surface pricing on /v1/models, so we emit a zeroed cost block.

 *      Downstream OC reads this for display only β€” the live pricing is

 *      OmniRoute's responsibility at routing time.

 *   3. `tool_call` (spec) β†’ `toolcall` (ModelV2 field name; one word).

 *   4. `attachment` (spec) maps from `capabilities.vision` per OmniRoute

 *      convention: vision = ability to receive image attachments. If the

 *      raw entry happens to expose an explicit `capabilities.attachment`

 *      (some combo entries do), that wins.

 *   5. `thinking` from OmniRoute has no 1:1 ModelV2 slot. We OR it into

 *      `reasoning` so thinking-only models still surface a non-false

 *      reasoning flag.

 *   6. `last_updated` from OmniRoute has no ModelV2 slot β€” dropped (the

 *      spec also flagged this as "may not exist", and the prod sample

 *      confirms it's optional). `release_date` lands in ModelV2.release_date

 *      with `""` fallback (the field is required as `string`).

 *   7. `temperature: true` per OmniRoute convention (OpenAI-compat mode

 *      always supports the temperature knob). If a raw entry sets

 *      `capabilities.temperature` explicitly, that wins.

 *   8. Input/output modality arrays: each known modality flips its boolean.

 *      Unknown strings (future OmniRoute additions) are ignored β€” when the

 *      server adds new modalities we can map them here without breaking

 *      existing entries.

 *   9. `status: "active"` β€” OmniRoute doesn't tier models alpha/beta on

 *      /v1/models, and OC needs a non-deprecated status to expose the

 *      model in the picker. If a future entry surfaces an explicit

 *      lifecycle hint we can map it then.

 *  10. `options: {}` and `headers: {}` left empty β€” they're escape hatches

 *      for OC users to attach per-model overrides; the provider plugin

 *      must not preempt them.

 *  11. `limit.input` is OPTIONAL on ModelV2 (the `?` modifier). We only

 *      emit it when OmniRoute supplies `max_input_tokens` β€” keeps the

 *      shape clean for combo entries that only carry context_length.

 */

export function mapRawModelToModelV2(

  raw: OmniRouteRawModelEntry,

  ctx: { providerId: string; baseURL: string }

): ModelV2 {
  const caps = raw.capabilities ?? {};
  const inMods = new Set(raw.input_modalities ?? ["text"]);
  const outMods = new Set(raw.output_modalities ?? ["text"]);

  return {
    id: raw.id,
    /**

     * Display name. Falls back to raw.id when no enrichment is available;

     * the caller (`createOmniRouteProviderHook`) overlays

     * `/api/pricing/models` data via `applyEnrichment` when

     * `features.enrichment` is true.

     */
    name: raw.id,
    capabilities: {
      temperature: caps.temperature ?? true,
      reasoning: Boolean(caps.reasoning || caps.thinking),
      attachment: Boolean(caps.attachment ?? caps.vision ?? false),
      toolcall: Boolean(caps.tool_calling ?? false),
      input: {
        text: inMods.has("text"),
        audio: inMods.has("audio"),
        image: inMods.has("image"),
        video: inMods.has("video"),
        pdf: inMods.has("pdf"),
      },
      output: {
        text: outMods.has("text"),
        audio: outMods.has("audio"),
        image: outMods.has("image"),
        video: outMods.has("video"),
        pdf: outMods.has("pdf"),
      },
      interleaved: Boolean(caps.thinking),
    },
    cost: {
      input: 0,
      output: 0,
      cache: { read: 0, write: 0 },
    },
    limit: {
      context: typeof raw.context_length === "number" ? raw.context_length : 0,
      ...(typeof raw.max_input_tokens === "number" ? { input: raw.max_input_tokens } : {}),
      output: typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : 0,
    },
    status: "active",
    options: {},
    headers: {},
    release_date: raw.release_date ?? "",
    providerID: ctx.providerId,
    api: {
      id: "openai-compatible",
      url: ctx.baseURL,
      npm: "@ai-sdk/openai-compatible",
    },
  };
}

// ────────────────────────────────────────────────────────────────────────────
// Combo discovery (T-05) β€” /api/combos pass-through with LCD capability roll-up
// ────────────────────────────────────────────────────────────────────────────

/**

 * Raw shape of a single combo entry as returned by OmniRoute's `/api/combos`.

 *

 * Schema established via a live probe against

 * an OmniRoute `/api/combos` endpoint with a management-scoped key

 * (response saved at /tmp/t05-combos.json) cross-referenced against the

 * source-of-truth in this repo:

 *

 *   - `src/app/api/combos/route.ts` GET handler β€” emits `{combos: [...]}`

 *     envelope after `getCombos()`.

 *   - `src/lib/db/combos.ts` `getCombos()` β€” returns rows persisted via

 *     `createCombo` / `updateCombo`, each shaped by `normalizeStoredCombo`.

 *   - `src/lib/combos/steps.ts` `ComboModelStep` + `ComboRefStep` β€” define

 *     the `models[]` array entry shape (a step references a member model

 *     by its full provider-prefixed id, e.g. `"claude-opus-4-5-thinking"`).

 *

 * Note: the preprod gateway returned `{combos: []}` at probe time (no combos

 * provisioned). The defensive parser accepts both `{combos:[...]}` and a

 * bare array envelope so the plugin keeps working if a future OmniRoute

 * build trims the wrapper (mirrors the same pattern in the sibling

 * `@omniroute/opencode-provider#listCombos`).

 *

 * STRICT source-of-truth (OQ-3, per T-03): every ModelV2 field a combo

 * surfaces traces back to either (a) this raw combo entry or (b) the LCD

 * roll-up across its raw member models. No client-side variant synthesis.

 */
export interface OmniRouteRawComboMemberRef {
  /** Step kind: "model" references a raw model id; "combo-ref" nests another combo. */
  kind?: "model" | "combo-ref";
  /** Full model id referenced by this step (when kind === "model"). */
  model?: string;
  /** Nested combo name (when kind === "combo-ref"). */
  comboName?: string;
  /** Routing weight inside the combo (0–100, advisory at LCD time). */
  weight?: number;
  /** Step-local label, distinct from the parent combo's display name. */
  label?: string;
}

export interface OmniRouteRawCombo {
  id: string;
  name?: string;
  /** Routing strategy. Surfaced for forward-compat but not consumed by LCD. */
  strategy?: string;
  /** Member step list. Only `kind: "model"` steps participate in LCD. */
  models?: OmniRouteRawComboMemberRef[];
  /** Hidden combos are excluded from the OC model picker. */
  isHidden?: boolean;
  /** When OmniRoute attaches a lifecycle hint we forward it; today it doesn't. */
  release_date?: string;
}

/**

 * Fetcher contract for `/api/combos`. Same DI shape as

 * `OmniRouteModelsFetcher` so unit tests can inject a stub instead of

 * monkey-patching global `fetch`.

 */
export type OmniRouteCombosFetcher = (

  baseURL: string,

  apiKey: string,

  timeoutMs?: number

) => Promise<OmniRouteRawCombo[]>;

/**

 * Default fetcher: `GET <baseURL>/api/combos` with bearer auth +

 * AbortController timeout. Accepts both the `{combos: [...]}` envelope the

 * gateway emits today and a bare-array envelope (defensive β€” keeps the

 * plugin working if a future OmniRoute build trims the wrapper).

 *

 * Differences from `defaultOmniRouteModelsFetcher`:

 *   - URL is `/api/combos`, NOT `/v1/combos`. The `/v1/...` namespace is the

 *     OpenAI-compatible surface (chat completions, models); combo discovery

 *     lives on the management plane under `/api/...`. We tolerate both

 *     `https://host` and `https://host/v1` baseURL forms by stripping the

 *     trailing `/v1` segment before appending `/api/combos`.

 *   - Combos endpoint requires a management-scoped API key when

 *     `REQUIRE_API_KEY` is enabled. We don't enforce that here; the

 *     gateway returns 401/403 with an actionable error which we propagate.

 *

 * Anything that isn't an object with a string `id` is filtered out silently.

 */
export const defaultOmniRouteCombosFetcher: OmniRouteCombosFetcher = async (
  baseURL,
  apiKey,
  timeoutMs = 10_000
) => {
  if (!apiKey) throw new Error("@omniroute/opencode-plugin: apiKey required to fetch /api/combos");
  if (!baseURL)
    throw new Error("@omniroute/opencode-plugin: baseURL required to fetch /api/combos");

  // Strip trailing slashes, then strip a trailing `/v1` so we land on the
  // management plane. Models live under `/v1/models`; combos live under
  // `/api/combos` from the same gateway root.
  const trimmed = trimTrailingSlashes(baseURL);
  const root = trimmed.replace(/\/v\d+$/, "");
  const url = `${root}/api/combos`;

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const res = await fetch(url, {
      method: "GET",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        Accept: "application/json",
      },
      signal: controller.signal,
    });
    if (!res.ok) {
      throw new Error(
        `@omniroute/opencode-plugin: GET ${url} failed: ${res.status} ${res.statusText}`
      );
    }
    const body = (await res.json()) as unknown;
    const rawList: unknown[] = Array.isArray(body)
      ? body
      : body && typeof body === "object" && Array.isArray((body as { combos?: unknown }).combos)
        ? ((body as { combos: unknown[] }).combos as unknown[])
        : [];
    const out: OmniRouteRawCombo[] = [];
    for (const r of rawList) {
      if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") {
        out.push(r as OmniRouteRawCombo);
      }
    }
    return out;
  } finally {
    clearTimeout(timer);
  }
};

/**

 * Map a raw combo entry β†’ `ModelV2` by computing the lowest-common-denominator

 * (LCD) of its underlying member models. The LCD policy is the only way to

 * surface a single capability vector to OpenCode without lying: if any member

 * lacks a capability, the combo as a whole cannot guarantee it.

 *

 * LCD rules:

 *   - `limit.context` = `min(...members.context_length)`.

 *   - `limit.output` = `min(...members.max_output_tokens)`.

 *   - `limit.input` = `min(...members.max_input_tokens)` ONLY when every

 *     member declares one (ModelV2.limit.input is optional β€” better to

 *     omit than to fabricate a min over partial data).

 *   - `capabilities.toolcall` / `reasoning` / `attachment` / `temperature`:

 *     `every(member β‡’ supports?)`. The `reasoning` axis ORs across

 *     `reasoning` and `thinking` per member before AND-ing across the

 *     combo (mirrors `mapRawModelToModelV2`). The `attachment` axis ORs

 *     across `attachment` and `vision` per member. The `temperature` axis

 *     uses default-true semantics: a member supports temperature unless

 *     it explicitly declares `temperature: false`.

 *   - `capabilities.input.*` / `output.*`: flattened AND across members'

 *     modality flags. Missing arrays default to `["text"]` (same default

 *     as `mapRawModelToModelV2`).

 *

 * Defensive: empty members array β†’ ALL capabilities `false`, limits zero.

 * That's an intentional safety posture (you can't route through an empty

 * combo, so OC should grey it out in the picker).

 *

 * Spec mapping (T-05 Β§Scope.3): `cost` zeroed; `status = "active"`;

 * `release_date = combo.release_date ?? ""`; `api.id = "openai-compatible"`;

 * `name = combo.name ?? combo.id`.

 *

 * @param combo Raw `/api/combos` entry.

 * @param members Raw `/v1/models` entries for THIS combo's member ids.

 *                Caller resolves `combo.models[].model` ids; unknown ids

 *                are silently dropped before this call.

 * @param providerId OpenCode provider id (multi-instance aware).

 * @param baseURL Resolved gateway base URL for ModelV2.api.url.

 */
export function mapComboToModelV2(

  combo: OmniRouteRawCombo,

  members: OmniRouteRawModelEntry[],

  providerId: string,

  baseURL: string

): ModelV2 {
  // `every` over an empty array returns true (would lie about an empty
  // combo's capabilities) β€” short-circuit to all-false when no members.
  const hasMembers = members.length > 0;

  const memberInMods = members.map((m) => new Set(m.input_modalities ?? ["text"]));
  const memberOutMods = members.map((m) => new Set(m.output_modalities ?? ["text"]));

  const modalityAllHave = (sets: Array<Set<string>>, key: string): boolean =>
    hasMembers && sets.every((s) => s.has(key));

  const contextValues = members
    .map((m) => m.context_length)
    .filter((v): v is number => typeof v === "number" && v > 0);
  const outputValues = members
    .map((m) => m.max_output_tokens)
    .filter((v): v is number => typeof v === "number" && v > 0);
  const inputValues = members
    .map((m) => m.max_input_tokens)
    .filter((v): v is number => typeof v === "number" && v > 0);

  const everyDeclaresInput = hasMembers && inputValues.length === members.length;

  const capabilities: ModelV2["capabilities"] = {
    temperature:
      hasMembers && members.every((m) => (m.capabilities?.temperature ?? true) !== false),
    reasoning:
      hasMembers &&
      members.every((m) => Boolean(m.capabilities?.reasoning || m.capabilities?.thinking)),
    attachment:
      hasMembers &&
      members.every((m) => Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false)),
    toolcall: hasMembers && members.every((m) => Boolean(m.capabilities?.tool_calling ?? false)),
    input: {
      text: modalityAllHave(memberInMods, "text"),
      audio: modalityAllHave(memberInMods, "audio"),
      image: modalityAllHave(memberInMods, "image"),
      video: modalityAllHave(memberInMods, "video"),
      pdf: modalityAllHave(memberInMods, "pdf"),
    },
    output: {
      text: modalityAllHave(memberOutMods, "text"),
      audio: modalityAllHave(memberOutMods, "audio"),
      image: modalityAllHave(memberOutMods, "image"),
      video: modalityAllHave(memberOutMods, "video"),
      pdf: modalityAllHave(memberOutMods, "pdf"),
    },
    interleaved: hasMembers && members.every((m) => Boolean(m.capabilities?.thinking)),
  };

  return {
    id: combo.id,
    providerID: providerId,
    api: {
      id: "openai-compatible",
      url: baseURL,
      npm: "@ai-sdk/openai-compatible",
    },
    name: combo.name && combo.name.trim().length > 0 ? combo.name : combo.id,
    capabilities,
    cost: {
      input: 0,
      output: 0,
      cache: { read: 0, write: 0 },
    },
    limit: {
      context: contextValues.length > 0 ? Math.min(...contextValues) : 0,
      ...(everyDeclaresInput ? { input: Math.min(...inputValues) } : {}),
      output: outputValues.length > 0 ? Math.min(...outputValues) : 0,
    },
    status: "active",
    options: {},
    headers: {},
    release_date: combo.release_date ?? "",
  };
}

// ─────────────────────────────────────────────────────────────────────────
// ENRICHMENT β€” pull display names + pricing from /api/pricing/models so
// the UI doesn't have to render raw model ids. Gated by features.enrichment.
// ─────────────────────────────────────────────────────────────────────────

/**

 * Per-model enrichment overlay derived from OmniRoute's

 * `/api/pricing/models` endpoint. The endpoint returns a per-provider

 * catalog with curated `name` strings (e.g. `Claude 4.7 Opus`,

 * `GPT 5.5 Pro`, `Gemini 3.1 Pro`) and per-million-token pricing

 * (`pricing.input`, `pricing.output`, `pricing.cacheRead`,

 * `pricing.cacheWrite`). These overlay the ModelV2 entries produced by

 * `mapRawModelToModelV2`.

 */
export interface OmniRouteEnrichmentEntry {
  /** Human-readable display name. Replaces ModelV2.name when present. */
  name?: string;
  /** Per-million-token cost overlay onto ModelV2.cost. */
  pricing?: {
    input?: number;
    output?: number;
    cacheRead?: number;
    cacheWrite?: number;
  };
  /**

   * Provider alias prefix seen in `/v1/models` ids (e.g. `cc`, `gemini-cli`).

   * Populated by `defaultOmniRouteEnrichmentFetcher` from

   * `/api/pricing/models` keys. Drives the `usableOnly` alias↔canonical

   * resolution.

   */
  providerAlias?: string;
  /**

   * Canonical provider id used by `/api/providers` connections (e.g.

   * `claude`, `gemini-cli`, `kiro`). Populated from the per-provider

   * `entry.id` field inside `/api/pricing/models`.

   */
  providerCanonical?: string;
  /**

   * Human-readable upstream provider label (e.g. `Claude`, `Kiro`,

   * `Windsurf`, `GitHub Models`). Populated from the per-provider

   * `entry.name` field inside `/api/pricing/models`. Used by the

   * `providerTag` feature to suffix `ModelV2.name` with the routing

   * destination so the OC TUI picker can differentiate the same

   * model id sold through different upstream connections.

   */
  providerDisplayName?: string;
}

/** Map keyed by full model id (possibly namespaced, e.g. `cc/claude-sonnet-4-6`). */
export type OmniRouteEnrichmentMap = Map<string, OmniRouteEnrichmentEntry>;

export type OmniRouteEnrichmentFetcher = (

  baseURL: string,

  apiKey: string,

  timeoutMs?: number

) => Promise<OmniRouteEnrichmentMap>;

/**

 * Default enrichment fetcher β€” pulls nice display names from

 * `GET /api/pricing/models` and merges per-million-token pricing from

 * `GET /api/pricing` (the actual pricing source β€” `/api/pricing/models` is

 * a catalog endpoint whose entries are `{id, name, custom}` only).

 *

 * `/api/pricing/models` shape (catalog):

 *  - `{ [providerAlias]: { id, alias, name, models: [{ id, name, custom }] } }`

 *

 * `/api/pricing` shape (pricing only):

 *  - `{ [providerAlias]: { [modelId]: { input, output, cached, reasoning, cache_creation } } }`

 *    where values are USD per million tokens.

 *

 * The two responses are joined on `(providerAlias, modelId)` and the merged

 * entries are stored under both `${providerAlias}/${modelId}` and bare

 * `${modelId}` keys so downstream lookups against either form succeed.

 *

 * Soft-fails (returns whatever was collected) on non-2xx or parse errors;

 * the two fetches are independent so one missing source still surfaces the

 * other.

 */
export const defaultOmniRouteEnrichmentFetcher: OmniRouteEnrichmentFetcher = async (
  baseURL,
  apiKey,
  timeoutMs = 10_000
) => {
  const out: OmniRouteEnrichmentMap = new Map();
  if (!baseURL || !apiKey) return out;
  const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, "");
  const headers = {
    Authorization: `Bearer ${apiKey}`,
    Accept: "application/json",
  };

  // ── 1. Catalog with nice display names ────────────────────────────────
  const catalogAc = new AbortController();
  const catalogTimer = setTimeout(() => catalogAc.abort(), timeoutMs);
  try {
    const res = await fetch(`${root}/api/pricing/models`, {
      method: "GET",
      headers,
      signal: catalogAc.signal,
    });
    if (res.ok) {
      const body = (await res.json()) as unknown;
      const providers =
        (body as { providers?: Record<string, { models?: unknown[] }> })?.providers ??
        (body as Record<string, { models?: unknown[] }>);
      if (providers && typeof providers === "object") {
        for (const [providerAlias, slot] of Object.entries(providers)) {
          if (!slot || typeof slot !== "object") continue;
          const models = (slot as { models?: unknown[] }).models;
          if (!Array.isArray(models)) continue;
          // Canonical id sits at the per-provider top level (e.g.
          // `pricing-models.cc.id === 'claude'`). Falls back to the alias
          // itself when missing β€” common case alias===canonical.
          const canonicalRaw = (slot as { id?: unknown }).id;
          const providerCanonical =
            typeof canonicalRaw === "string" && canonicalRaw.length > 0
              ? canonicalRaw
              : providerAlias;
          // Upstream provider human label (e.g. `Claude`, `Kiro`,
          // `GitHub Models`). Optional β€” falls back to undefined when
          // OmniRoute hasn't curated a label for this slot.
          const slotNameRaw = (slot as { name?: unknown }).name;
          const providerDisplayName =
            typeof slotNameRaw === "string" && slotNameRaw.trim().length > 0
              ? slotNameRaw.trim()
              : undefined;
          for (const m of models) {
            if (!m || typeof m !== "object") continue;
            const id = (m as { id?: unknown }).id;
            if (typeof id !== "string" || id.length === 0) continue;
            const name = (m as { name?: unknown }).name;
            const entry: OmniRouteEnrichmentEntry = {
              providerAlias,
              providerCanonical,
            };
            if (providerDisplayName) entry.providerDisplayName = providerDisplayName;
            if (typeof name === "string" && name.trim().length > 0) entry.name = name;
            const namespaced = `${providerAlias}/${id}`;
            if (!out.has(namespaced)) out.set(namespaced, entry);
            if (!out.has(id)) out.set(id, entry);
          }
        }
      }
    }
  } catch {
    // Soft-fail; keep going to pricing fetch.
  } finally {
    clearTimeout(catalogTimer);
  }

  // ── 2. Pricing values from /api/pricing ───────────────────────────────
  const priceAc = new AbortController();
  const priceTimer = setTimeout(() => priceAc.abort(), timeoutMs);
  try {
    const res = await fetch(`${root}/api/pricing`, {
      method: "GET",
      headers,
      signal: priceAc.signal,
    });
    if (res.ok) {
      const body = (await res.json()) as unknown;
      if (body && typeof body === "object" && !Array.isArray(body)) {
        for (const [providerAlias, slot] of Object.entries(body as Record<string, unknown>)) {
          if (!slot || typeof slot !== "object" || Array.isArray(slot)) continue;
          for (const [modelId, raw] of Object.entries(slot as Record<string, unknown>)) {
            if (!raw || typeof raw !== "object") continue;
            const p = raw as Record<string, unknown>;
            const parsed: NonNullable<OmniRouteEnrichmentEntry["pricing"]> = {};
            // OmniRoute `/api/pricing` keys:
            //   input         β†’ cost.input
            //   output        β†’ cost.output
            //   cached        β†’ cost.cache.read   (alias: cacheRead)
            //   cache_creation β†’ cost.cache.write (alias: cacheWrite)
            // Tolerate alternative spellings for forward-compat.
            if (typeof p.input === "number") parsed.input = p.input;
            if (typeof p.output === "number") parsed.output = p.output;
            const cacheRead =
              typeof p.cached === "number"
                ? p.cached
                : typeof p.cacheRead === "number"
                  ? p.cacheRead
                  : undefined;
            if (typeof cacheRead === "number") parsed.cacheRead = cacheRead;
            const cacheWrite =
              typeof p.cache_creation === "number"
                ? p.cache_creation
                : typeof p.cacheWrite === "number"
                  ? p.cacheWrite
                  : undefined;
            if (typeof cacheWrite === "number") parsed.cacheWrite = cacheWrite;
            if (Object.keys(parsed).length === 0) continue;
            const namespaced = `${providerAlias}/${modelId}`;
            const existingNs = out.get(namespaced);
            if (existingNs) existingNs.pricing = { ...(existingNs.pricing ?? {}), ...parsed };
            else out.set(namespaced, { pricing: parsed });
            const existingBare = out.get(modelId);
            if (existingBare) existingBare.pricing = { ...(existingBare.pricing ?? {}), ...parsed };
            else out.set(modelId, { pricing: parsed });
          }
        }
      }
    }
  } catch {
    // Soft-fail; return whatever names we collected.
  } finally {
    clearTimeout(priceTimer);
  }

  return out;
};

/**

 * Separator used by `applyProviderTag` between the upstream provider

 * label (prefix) and the enriched model name. ASCII hyphen with

 * surrounding spaces β€” terminal-safe everywhere, never collides with

 * a model id (those use slashes / dots / underscores).

 *

 * Layout: `<short-label> - <model name>` (label leads so column scans

 * group by provider β€” e.g. `Claude - Claude Opus 4.7`,

 * `Kiro - Claude Opus 4.7`).

 */
export const PROVIDER_TAG_SEPARATOR = " - ";

/**

 * Threshold beyond which `providerDisplayName` is abbreviated. Raised

 * from 8 β†’ 12 so curated brand casing (`AssemblyAI`, `Antigravity`,

 * `Pollinations`, `GEMINI-CLI` curated form) wins over a shouty

 * UPPER(alias) fallback for the common case.

 */
const PROVIDER_LABEL_MAX_CHARS = 12;

/**

 * Aliases longer than this get title-case fallback instead of UPPER β€”

 * keeps short-token UPPER (`cc`β†’`CC`, `ghm`β†’`GHM`) but tames long

 * lowercase aliases (`antigravity`β†’`Antigravity`).

 */
const ALIAS_UPPER_MAX_CHARS = 5;

/**

 * Title-case a long, lowercase-looking alias (e.g. `antigravity` β†’

 * `Antigravity`) so the prefix doesn't shout when neither

 * `providerDisplayName` nor a short alias is available.

 */
function titleCaseAlias(alias: string): string {
  if (alias.length === 0) return alias;
  return alias.charAt(0).toUpperCase() + alias.slice(1).toLowerCase();
}

/**

 * Pick the short label for an upstream provider that goes into the

 * `<label> - <model>` prefix.

 *

 * Rule (matches user spec β€” no hardcoded registry, fully data-driven):

 *

 *   1. Trim `enrichment.providerDisplayName` (= `/api/pricing/models[<alias>].name`).

 *   2. If the trimmed label is non-empty AND ≀ {@link PROVIDER_LABEL_MAX_CHARS} (12),

 *      use it verbatim (e.g. `Claude`, `Kiro`, `AssemblyAI`, `Antigravity`).

 *   3. Otherwise look at the alias:

 *      - if the alias is short (≀ {@link ALIAS_UPPER_MAX_CHARS}) β†’

 *        `UPPER(alias)` (e.g. `cc` β†’ `CC`, `ghm` β†’ `GHM`).

 *      - if the alias is longer β†’ title-case it (`antigravity` β†’

 *        `Antigravity`) so the prefix is readable, not shouty.

 *   4. If neither field is usable, return `undefined` (caller should

 *      skip the prefix decoration).

 */
export function shortProviderLabel(

  enrichment: OmniRouteEnrichmentEntry | undefined

): string | undefined {
  if (!enrichment) return undefined;
  const raw =
    typeof enrichment.providerDisplayName === "string" ? enrichment.providerDisplayName.trim() : "";
  if (raw.length > 0 && raw.length <= PROVIDER_LABEL_MAX_CHARS) return raw;
  const alias = typeof enrichment.providerAlias === "string" ? enrichment.providerAlias.trim() : "";
  if (alias.length > 0) {
    return alias.length <= ALIAS_UPPER_MAX_CHARS ? alias.toUpperCase() : titleCaseAlias(alias);
  }
  // Tolerate "label too long + no alias" by falling back to the long
  // label itself β€” better than dropping the prefix entirely. Rare case.
  return raw.length > 0 ? raw : undefined;
}

/**

 * Prepend the upstream provider label to `model.name` so the OC TUI

 * picker can differentiate the same model id sold through different

 * upstream connections (e.g. `cc/claude-opus-4-7` via Anthropic

 * vs `kr/claude-opus-4-7` via Kiro). Result shape:

 *

 *   `<label>${PROVIDER_TAG_SEPARATOR}<enriched name>`

 *   β†’ `Claude - Claude Opus 4.7`

 *   β†’ `Kiro - Claude Opus 4.7`

 *   β†’ `AssemblyAI - Universal 2 (Transcription)` (slot.name fits, used verbatim)

 *   β†’ `GHM - GPT 5`           (slot.name "GitHub Models" > 12 chars β†’ UPPER(alias))

 *

 * Mutates the model in place and is idempotent β€” running twice never

 * double-prefixes. No-op when:

 *

 *  - `enrichment` is undefined,

 *  - {@link shortProviderLabel} returns `undefined`

 *    (no `providerDisplayName` AND no `providerAlias`),

 *  - the current `model.name` already starts with the prefix.

 *

 * Combos are intentionally skipped by callers (they're multi-upstream

 * by definition; the `Combo: ` prefix conveys that). Raw models call

 * this after `applyEnrichment` so the tag layers on top of the

 * friendly name.

 */
export function applyProviderTag(

  model: ModelV2,

  enrichment: OmniRouteEnrichmentEntry | undefined

): ModelV2 {
  const label = shortProviderLabel(enrichment);
  if (!label) return model;
  const prefix = `${label}${PROVIDER_TAG_SEPARATOR}`;
  if (model.name.startsWith(prefix)) return model;
  model.name = `${prefix}${model.name}`;
  return model;
}

/**

 * Reverse-index the enrichment map from `providerCanonical β†’ providerAlias`.

 *

 * OmniRoute's `/api/pricing/models` is keyed by short ALIAS (`cc`, `cx`,

 * `pol`). But `/v1/models` exposes some models a SECOND time under their

 * CANONICAL name (`claude/claude-opus-4-7`, `codex/gpt-5.5`,

 * `pollinations/midjourney`). Without a reverse map, those canonical

 * rows miss enrichment entirely and surface as raw ids in the picker.

 *

 * Built once per refresh from the enrichment entries themselves β€” no

 * hardcoded registry. Only records `canonical β†’ alias` mappings when

 * both are present AND distinct (skips slots where alias === canonical

 * like `kiro`).

 */
export function buildCanonicalToAliasMap(

  enrichment: OmniRouteEnrichmentMap | undefined

): Map<string, string> {
  const out = new Map<string, string>();
  if (!enrichment) return out;
  for (const entry of enrichment.values()) {
    const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : "";
    const canonical =
      typeof entry.providerCanonical === "string" ? entry.providerCanonical.trim() : "";
    if (alias.length === 0 || canonical.length === 0) continue;
    if (alias === canonical) continue;
    if (!out.has(canonical)) out.set(canonical, alias);
  }
  return out;
}

/**

 * Enrichment lookup with alias-fallback chain.

 *

 * Resolution order (first hit wins):

 *

 *   1. `enrichment.get(rawId)` β€” direct hit on `<prefix>/<modelId>` or

 *      bare id (the fetcher writes under both forms).

 *   2. If `rawId` is `<canonical>/<modelId>` and `canonicalToAlias` has

 *      a mapping for `canonical`, try `<alias>/<modelId>`. This rescues

 *      duplicate rows like `claude/claude-opus-4-7` (canonical) when

 *      enrichment only indexed under `cc/claude-opus-4-7` (alias).

 *   3. Bare `<modelId>` as a last resort. Already covered by step 1 in

 *      practice (fetcher writes bare keys), but kept defensive.

 *

 * Returns `undefined` when no lookup hits.

 */
export function lookupEnrichment(

  rawId: string,

  enrichment: OmniRouteEnrichmentMap | undefined,

  canonicalToAlias: Map<string, string>

): OmniRouteEnrichmentEntry | undefined {
  if (!enrichment) return undefined;
  const direct = enrichment.get(rawId);
  if (direct) return direct;
  const slash = rawId.indexOf("/");
  if (slash > 0) {
    const prefix = rawId.slice(0, slash);
    const modelId = rawId.slice(slash + 1);
    const alias = canonicalToAlias.get(prefix);
    if (alias && alias !== prefix) {
      const viaAlias = enrichment.get(`${alias}/${modelId}`);
      if (viaAlias) return viaAlias;
    }
    const bare = enrichment.get(modelId);
    if (bare) return bare;
  }
  return undefined;
}

/**

 * Pre-pass: detect raw rows that are the CANONICAL twin of an ALIAS row

 * already in the catalog. Returns the set of canonical-keyed ids to skip

 * during the raw-model loop so each model surfaces exactly once under

 * its enriched alias key.

 *

 * Example: `/v1/models` returns BOTH `cc/claude-opus-4-7` and

 * `claude/claude-opus-4-7`. The former is enriched (alias `cc` exists

 * in `/api/pricing/models`); the latter is raw. We keep `cc/...` and

 * drop `claude/...`.

 *

 * Built once per refresh. Cheap β€” O(M) where M = raw model count.

 */
export function canonicalDedupSet(

  rawModels: ReadonlyArray<OmniRouteRawModelEntry>,

  canonicalToAlias: Map<string, string>

): Set<string> {
  const drop = new Set<string>();
  if (canonicalToAlias.size === 0) return drop;
  // Index every alias key present in the raw catalog.
  const aliasKeys = new Set<string>();
  for (const m of rawModels) {
    if (typeof m.id === "string" && m.id.length > 0) aliasKeys.add(m.id);
  }
  for (const m of rawModels) {
    if (typeof m.id !== "string" || m.id.length === 0) continue;
    const slash = m.id.indexOf("/");
    if (slash <= 0) continue;
    const prefix = m.id.slice(0, slash);
    const modelId = m.id.slice(slash + 1);
    const alias = canonicalToAlias.get(prefix);
    if (!alias || alias === prefix) continue;
    // Canonical row only gets suppressed if the alias row actually
    // exists β€” otherwise we'd hide the model entirely.
    if (aliasKeys.has(`${alias}/${modelId}`)) drop.add(m.id);
  }
  return drop;
}

/**

 * Build a per-alias index of enrichment metadata so we can render the

 * provider prefix even for raw models that don't have their own

 * curated `/api/pricing/models` entry.

 *

 * Real example: OmniRoute's `pricing['cohere']` slot lists 10 curated

 * models but `/v1/models` also returns `cohere/rerank-multilingual-v3.0`

 * and `cohere/rerank-v4.0-fast` (not in the curated 10). Without this

 * index, those rows surface in the picker as `cohere/...` with no

 * `Cohere - ` prefix because the per-model enrichment lookup misses.

 *

 * This index records the first non-empty `providerDisplayName` seen

 * for each alias, plus the alias itself. Callers use it to synthesize

 * a minimal `OmniRouteEnrichmentEntry` whenever the direct lookup

 * misses but the raw id's prefix matches a known alias.

 *

 * Built once per refresh; first-wins on duplicate alias (matches

 * `buildCanonicalToAliasMap` semantics).

 */
export function buildAliasIndex(

  enrichment: OmniRouteEnrichmentMap | undefined

): Map<string, OmniRouteEnrichmentEntry> {
  const out = new Map<string, OmniRouteEnrichmentEntry>();
  if (!enrichment) return out;
  for (const entry of enrichment.values()) {
    const alias = typeof entry.providerAlias === "string" ? entry.providerAlias.trim() : "";
    if (alias.length === 0) continue;
    if (out.has(alias)) {
      // First-wins, but upgrade to the first entry that carries a
      // non-empty providerDisplayName so the prefix renders nicely.
      const existing = out.get(alias);
      if (
        existing &&
        (!existing.providerDisplayName || existing.providerDisplayName.trim().length === 0) &&
        typeof entry.providerDisplayName === "string" &&
        entry.providerDisplayName.trim().length > 0
      ) {
        out.set(alias, entry);
      }
      continue;
    }
    out.set(alias, entry);
  }
  return out;
}

/**

 * Resolve a synthesised enrichment entry for `applyProviderTag` /

 * `shortProviderLabel` consumption, combining two sources:

 *

 *  1. The direct per-model enrichment match (if present).

 *  2. A per-alias fallback derived from `buildAliasIndex` β€” covers raw

 *     ids whose prefix matches a known alias but the specific model

 *     id wasn't curated in `/api/pricing/models`. Example:

 *     `cohere/rerank-multilingual-v3.0` falls back to the cohere slot's

 *     `providerDisplayName='Cohere'` even though that specific id

 *     isn't in the curated 10-model list.

 *

 * Returns `undefined` when neither source surfaces an alias.

 *

 * NOTE: this function is read-only over its inputs; it never mutates

 * the underlying `direct` entry. When it falls back to the alias

 * index, it constructs a fresh minimal entry exposing only the

 * provider-prefix fields (`providerAlias`, `providerCanonical`,

 * `providerDisplayName`). Other fields (name, pricing) are explicitly

 * left undefined so `applyEnrichment` won't accidentally overwrite a

 * model name with the alias-slot label.

 */
export function resolveProviderTagEntry(

  rawId: string,

  direct: OmniRouteEnrichmentEntry | undefined,

  aliasIndex: Map<string, OmniRouteEnrichmentEntry>,

  canonicalToAlias?: Map<string, string>

): OmniRouteEnrichmentEntry | undefined {
  if (direct) {
    const alias = typeof direct.providerAlias === "string" ? direct.providerAlias.trim() : "";
    const display =
      typeof direct.providerDisplayName === "string" ? direct.providerDisplayName.trim() : "";
    if (alias.length > 0 || display.length > 0) return direct;
  }
  const slash = rawId.indexOf("/");
  if (slash <= 0) return direct;
  const prefix = rawId.slice(0, slash);
  // 1. Direct alias lookup (`cohere/...` β†’ cohere slot keyed by alias=cohere).
  let fromAlias = aliasIndex.get(prefix);
  // 2. Canonical fallback (`pollinations/...` β†’ look up via alias `pol`).
  if (!fromAlias && canonicalToAlias) {
    const alias = canonicalToAlias.get(prefix);
    if (alias) fromAlias = aliasIndex.get(alias);
  }
  if (!fromAlias) return direct;
  // Synthesize: borrow only the provider-prefix metadata.
  return {
    providerAlias: fromAlias.providerAlias,
    providerCanonical: fromAlias.providerCanonical,
    providerDisplayName: fromAlias.providerDisplayName,
  };
}

/**

 * Apply enrichment overlay onto a ModelV2 entry. Mutates and returns the

 * passed entry for convenience.

 */
export function applyEnrichment(

  model: ModelV2,

  enrichment: OmniRouteEnrichmentEntry | undefined

): ModelV2 {
  if (!enrichment) return model;
  if (enrichment.name && enrichment.name.trim().length > 0) {
    model.name = enrichment.name;
  }
  if (enrichment.pricing) {
    if (typeof enrichment.pricing.input === "number") {
      model.cost.input = enrichment.pricing.input;
    }
    if (typeof enrichment.pricing.output === "number") {
      model.cost.output = enrichment.pricing.output;
    }
    if (typeof enrichment.pricing.cacheRead === "number") {
      model.cost.cache.read = enrichment.pricing.cacheRead;
    }
    if (typeof enrichment.pricing.cacheWrite === "number") {
      model.cost.cache.write = enrichment.pricing.cacheWrite;
    }
  }
  return model;
}

// ─────────────────────────────────────────────────────────────────────────
// COMPRESSION METADATA β€” pull /api/context/combos so combo entries can be
// tagged with their compression pipeline. Gated by
// features.compressionMetadata (off by default).
// ─────────────────────────────────────────────────────────────────────────

/** Single step in a compression combo's pipeline. */
export interface OmniRouteCompressionStep {
  engine: string; // "rtk" | "caveman" | "aggressive" | ...
  intensity?: string; // "minimal" | "lite" | "standard" | "full" | "ultra" | "aggressive"
}

/** Compression combo as returned by /api/context/combos. */
export interface OmniRouteCompressionCombo {
  id: string;
  name?: string;
  description?: string;
  pipeline: OmniRouteCompressionStep[];
  isDefault?: boolean;
}

export type OmniRouteCompressionMetaFetcher = (

  baseURL: string,

  apiKey: string,

  timeoutMs?: number

) => Promise<OmniRouteCompressionCombo[]>;

/**

 * Default compression-metadata fetcher β€” calls `GET /api/context/combos`.

 * Tolerates envelope shapes `{ combos: [...] }`, `[...]`, or

 * `{ data: [...] }`. Soft-fails (returns []) on non-2xx or parse errors.

 */
export const defaultOmniRouteCompressionMetaFetcher: OmniRouteCompressionMetaFetcher = async (
  baseURL,
  apiKey,
  timeoutMs = 10_000
) => {
  const empty: OmniRouteCompressionCombo[] = [];
  if (!baseURL || !apiKey) return empty;
  const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, "");
  const url = `${root}/api/context/combos`;
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), timeoutMs);
  try {
    const res = await fetch(url, {
      method: "GET",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        Accept: "application/json",
      },
      signal: ac.signal,
    });
    if (!res.ok) return empty;
    const body = (await res.json()) as unknown;
    const list = Array.isArray(body)
      ? body
      : Array.isArray((body as { combos?: unknown[] })?.combos)
        ? (body as { combos: unknown[] }).combos
        : Array.isArray((body as { data?: unknown[] })?.data)
          ? (body as { data: unknown[] }).data
          : [];
    const out: OmniRouteCompressionCombo[] = [];
    for (const raw of list) {
      if (!raw || typeof raw !== "object") continue;
      const id = (raw as { id?: unknown }).id;
      const pipeline = (raw as { pipeline?: unknown }).pipeline;
      if (typeof id !== "string" || id.length === 0) continue;
      if (!Array.isArray(pipeline)) continue;
      const steps: OmniRouteCompressionStep[] = [];
      for (const step of pipeline) {
        if (!step || typeof step !== "object") continue;
        const engine = (step as { engine?: unknown }).engine;
        if (typeof engine !== "string" || engine.length === 0) continue;
        const intensity = (step as { intensity?: unknown }).intensity;
        const entry: OmniRouteCompressionStep = { engine };
        if (typeof intensity === "string" && intensity.length > 0) {
          entry.intensity = intensity;
        }
        steps.push(entry);
      }
      const combo: OmniRouteCompressionCombo = { id, pipeline: steps };
      const name = (raw as { name?: unknown }).name;
      if (typeof name === "string" && name.length > 0) combo.name = name;
      const description = (raw as { description?: unknown }).description;
      if (typeof description === "string") combo.description = description;
      const isDefault = (raw as { isDefault?: unknown }).isDefault;
      if (typeof isDefault === "boolean") combo.isDefault = isDefault;
      out.push(combo);
    }
    return out;
  } catch {
    return empty;
  } finally {
    clearTimeout(timer);
  }
};

/**

 * Map of well-known compression-intensity tokens to a single emoji

 * conveying "how much" compression is applied. Traffic-light palette:

 *

 *   🟒 minimal / lite   β€” almost no loss

 *   🟑 standard          β€” balanced

 *   🟠 aggressive / full β€” heavy

 *   πŸ”΄ ultra             β€” extreme

 *

 * Lookup is case-insensitive. Unknown intensities fall through to the

 * raw text form (`engine:<intensity>`) so we never hide a value that

 * OmniRoute knows but the plugin doesn't.

 *

 * Exported for callers (and tests) that want to assemble their own

 * pipeline strings.

 */
export const COMPRESSION_INTENSITY_EMOJI: Record<string, string> = {
  minimal: "🟒",
  lite: "🟒",
  standard: "🟑",
  aggressive: "🟠",
  full: "🟠",
  ultra: "πŸ”΄",
};

/**

 * Format a compression pipeline as a short human-readable string for

 * combo `name` decoration. Intensity tokens render as a traffic-light

 * emoji so a column scan reveals "how compressed" the combo is at a

 * glance:

 *

 *   `[rtk🟑 β†’ caveman🟠]`    (rtk:standard β†’ caveman:full)

 *   `[rtkπŸ”΄]`                 (rtk:ultra, single-step)

 *   `[caveman]`               (engine without intensity, no emoji)

 *   `[rtk:custom-thing]`      (unknown intensity, raw-text fallback)

 */
export function formatCompressionPipeline(pipeline: OmniRouteCompressionStep[]): string {
  if (!pipeline || pipeline.length === 0) return "";
  return (
    "[" +
    pipeline
      .map((s) => {
        if (!s.intensity) return s.engine;
        const emoji = COMPRESSION_INTENSITY_EMOJI[s.intensity.toLowerCase()];
        return emoji ? `${s.engine}${emoji}` : `${s.engine}:${s.intensity}`;
      })
      .join(" β†’ ") +
    "]"
  );
}

// ─────────────────────────────────────────────────────────────────────────
// /api/providers (provider-connection status) β€” optional read used by the
// `features.usableOnly` filter. Returns the operator's installed OmniRoute
// provider connections, each with `provider` (canonical id), `isActive`,
// `testStatus`. We treat a provider as USABLE when at least one of its
// connections is `isActive: true && testStatus: 'active'`. Aliases (e.g.
// `cc β†’ claude`) are resolved through the enrichment map.
// ─────────────────────────────────────────────────────────────────────────

/** Subset of `/api/providers/connections[]` we read. Other fields are kept as a permissive index signature. */
export interface OmniRouteProviderConnection {
  /** Connection UUID. */
  id: string;
  /** Canonical provider id, e.g. `claude`, `gemini-cli`, `kiro`. Matches `entry.id` in `/api/pricing/models`. */
  provider: string;
  /** Connection auth flavor, e.g. `apikey`, `oauth`, `cookie`. */
  authType?: string;
  /** Operator-visible label. */
  name?: string;
  /** Operator toggle β€” when false, the connection is provisioned but disabled. */
  isActive?: boolean;
  /** Health-check verdict β€” `active` means routable; `expired`/`error`/`unavailable` mean not. */
  testStatus?: string;
  /** Permissive bag β€” additional fields (priority, backoffLevel, etc.) pass through untouched. */
  [k: string]: unknown;
}

export type OmniRouteProvidersFetcher = (

  baseURL: string,

  apiKey: string,

  timeoutMs?: number

) => Promise<OmniRouteProviderConnection[]>;

/**

 * Default providers fetcher β€” calls `GET /api/providers`. Tolerates envelope

 * shapes `{ connections: [...] }`, `[...]`, or `{ data: [...] }`. Soft-fails

 * (returns []) on non-2xx or parse errors so the `usableOnly` filter

 * gracefully degrades to "no filter" instead of hiding the whole catalog.

 */
export const defaultOmniRouteProvidersFetcher: OmniRouteProvidersFetcher = async (
  baseURL,
  apiKey,
  timeoutMs = 10_000
) => {
  const empty: OmniRouteProviderConnection[] = [];
  if (!baseURL || !apiKey) return empty;
  const root = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, "");
  const url = `${root}/api/providers`;
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), timeoutMs);
  try {
    const res = await fetch(url, {
      method: "GET",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        Accept: "application/json",
      },
      signal: ac.signal,
    });
    if (!res.ok) return empty;
    const body = (await res.json()) as unknown;
    const list = Array.isArray(body)
      ? body
      : Array.isArray((body as { connections?: unknown[] })?.connections)
        ? (body as { connections: unknown[] }).connections
        : Array.isArray((body as { data?: unknown[] })?.data)
          ? (body as { data: unknown[] }).data
          : [];
    const out: OmniRouteProviderConnection[] = [];
    for (const raw of list) {
      if (!raw || typeof raw !== "object") continue;
      const provider = (raw as { provider?: unknown }).provider;
      if (typeof provider !== "string" || provider.length === 0) continue;
      const id = (raw as { id?: unknown }).id;
      const idStr = typeof id === "string" && id.length > 0 ? id : provider;
      out.push({ ...(raw as Record<string, unknown>), id: idStr, provider });
    }
    return out;
  } catch {
    return empty;
  } finally {
    clearTimeout(timer);
  }
};

/**

 * Compute the set of provider aliases that have at least one healthy,

 * active connection. Resolves alias β†’ canonical id through the enrichment

 * map (which is keyed under both `${alias}/${id}` and bare `${id}` β€” we

 * walk only the namespaced keys to derive the alias↔canonical mapping).

 *

 * Returns:

 *   - `aliases`: set of alias prefixes safe to keep (e.g. `cc`, `gemini-cli`).

 *   - `canonicals`: set of canonical provider ids (e.g. `claude`, `kiro`).

 *

 * Callers should treat membership in EITHER set as "usable" β€” raw model

 * ids may be `<alias>/<model>` (`cc/claude-opus-4-7`) OR `<canonical>/<model>`

 * (`claude/sonnet-4`) depending on the OmniRoute deployment's `/v1/models`

 * surface shape.

 *

 * Subtract-filter semantics: callers MUST also keep models whose prefix is

 * unknown to BOTH `/api/pricing/models` and `/api/providers` (e.g.

 * agentrouter-style synthetic prefixes). The right boolean is "if I see this

 * prefix in EITHER catalog table AND it's not usable, drop; otherwise keep".

 */
export function usableProviderAliasSet(

  connections: OmniRouteProviderConnection[],

  enrichment: OmniRouteEnrichmentMap | undefined

): { aliases: Set<string>; canonicals: Set<string>; knownAliases: Set<string> } {
  const usableCanonicals = new Set<string>();
  for (const c of connections) {
    if (!c || c.isActive !== true) continue;
    if (typeof c.testStatus === "string" && c.testStatus !== "active") continue;
    if (typeof c.provider === "string" && c.provider.length > 0) {
      usableCanonicals.add(c.provider);
    }
  }
  const aliases = new Set<string>();
  const knownAliases = new Set<string>();
  if (enrichment) {
    // Walk enrichment entries to map alias β†’ canonical via the metadata
    // populated by `defaultOmniRouteEnrichmentFetcher`. Every entry carries
    // its providerAlias + providerCanonical so the namespaced/bare key
    // duplication is harmless. Collect EVERY alias we encounter (regardless
    // of usability) into `knownAliases` so the downstream filter can decide
    // "this prefix was in /api/pricing/models" in O(1) instead of O(E).
    for (const entry of enrichment.values()) {
      const alias = entry.providerAlias;
      const canonical = entry.providerCanonical;
      if (typeof alias !== "string" || alias.length === 0) continue;
      knownAliases.add(alias);
      if (typeof canonical !== "string" || canonical.length === 0) continue;
      if (usableCanonicals.has(canonical)) aliases.add(alias);
    }
  }
  // Always include every usable canonical as an alias too β€” handles the
  // common case where `/v1/models` ids use the canonical id directly
  // (e.g. `gemini-cli/gemini-1.5-pro`).
  for (const canonical of usableCanonicals) aliases.add(canonical);
  return { aliases, canonicals: usableCanonicals, knownAliases };
}

/**

 * Decide whether a raw `/v1/models` id passes the `usableOnly` filter.

 *

 * Rules (subtract-filter β€” bias toward keep):

 *   - id has no `/` β†’ keep (combos/synthetic entries handled separately).

 *   - prefix matches a known usable alias OR canonical β†’ keep.

 *   - prefix is unknown to BOTH the connection table AND the enrichment

 *     map β†’ keep (we can't prove it's NOT usable; could be agentrouter).

 *   - prefix is known to the enrichment map BUT not in usable set β†’ drop.

 *

 * Pure function β€” exported so static + dynamic hooks share the same

 * verdict logic without divergence.

 */
export function isUsableRawModelId(

  id: string,

  usable: { aliases: Set<string>; canonicals: Set<string>; knownAliases: Set<string> },

  enrichment: OmniRouteEnrichmentMap | undefined

): boolean {
  const slash = id.indexOf("/");
  if (slash <= 0) return true;
  const prefix = id.slice(0, slash);
  if (usable.aliases.has(prefix) || usable.canonicals.has(prefix)) return true;
  // O(1) "known prefix" check via pre-calculated knownAliases set.
  // If prefix was in /api/pricing/models but is NOT in usable set,
  // drop the model. Unknown prefixes (e.g. agentrouter-style synthetic)
  // pass through (subtract-filter semantics).
  if (usable.knownAliases.has(prefix)) return false;
  return true;
}

/**

 * Decide whether a combo passes the `usableOnly` filter. A combo keeps

 * when AT LEAST ONE of its members maps to a usable canonical provider.

 * Combos with zero resolvable members pass through (already degraded to

 * all-false LCD posture and surfaced as cosmetic-only entries).

 */
export function isUsableCombo(

  combo: OmniRouteRawCombo,

  usable: { aliases: Set<string>; canonicals: Set<string>; knownAliases: Set<string> }

): boolean {
  const steps = Array.isArray(combo.models) ? combo.models : [];
  if (steps.length === 0) return true;
  // The provider id is folded INTO the full model string by OmniRoute's
  // `normalizeComboRecord` (e.g. "cc/claude-opus-4-7") β€” combo member refs do
  // NOT carry a separate `providerId` field. Derive the prefix from `step.model`
  // and apply the same subtract-filter verdict as `isUsableRawModelId`.
  let sawResolvableMember = false;
  for (const step of steps) {
    // Nested combo refs carry no model id we can resolve to a provider here.
    if (step?.kind === "combo-ref") continue;
    const modelId = typeof step?.model === "string" ? step.model : "";
    const slash = modelId.indexOf("/");
    if (slash <= 0) continue; // no provider prefix to evaluate
    sawResolvableMember = true;
    const prefix = modelId.slice(0, slash);
    if (usable.aliases.has(prefix) || usable.canonicals.has(prefix)) return true;
    // Unknown prefix (not in the known-alias universe) β†’ can't prove
    // unroutable; keep. Known-but-not-usable prefixes keep scanning.
    if (!usable.knownAliases.has(prefix)) return true;
  }
  // No member resolved to a provider prefix β†’ can't prove unroutable; keep.
  if (!sawResolvableMember) return true;
  // Every resolvable member used a known-but-non-usable prefix β†’ drop.
  return false;
}

/**

 * Slugify a combo display name into a copy/paste-friendly URL-safe segment.

 * Lowercases, replaces any run of non-alphanumeric chars with a single dash,

 * trims leading/trailing dashes. Empty input or all-special input returns

 * the empty string (caller must fall back to the combo's UUID id).

 *

 * Example: `Claude Tier` β†’ `claude-tier`, `GPT 5.5 / Pro` β†’ `gpt-5-5-pro`.

 */
export function slugifyComboName(name: string): string {
  if (typeof name !== "string") return "";
  return trimLeadingDashes(trimTrailingDashes(name.toLowerCase().replace(/[^a-z0-9]+/g, "-")));
}

/**

 * Build a combo's static-block key (`combo/<slug>`), guaranteeing uniqueness

 * across an entire static catalog. If `<slug>` is already present in `used`,

 * suffixes a short UUID-prefix disambiguator from `combo.id` so the second

 * combo doesn't silently overwrite the first. Mutates `used` in place by

 * recording the chosen key. Returns the final `combo/<...>` key.

 *

 * Falls back to `combo/<id>` when the friendly name slugifies to the empty

 * string (e.g. a combo named just punctuation).

 */
export function buildComboKey(combo: OmniRouteRawCombo, used: Set<string>): string {
  const friendlyName = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id;
  let slug = slugifyComboName(friendlyName);
  if (slug.length === 0) slug = combo.id;
  let key = `combo/${slug}`;
  if (used.has(key)) {
    const tail = combo.id.split("-")[0] ?? combo.id;
    key = `combo/${slug}-${tail}`;
    // Defensive: in the (impossible) event the disambiguated key also
    // collides, append the full id.
    if (used.has(key)) key = `combo/${slug}-${combo.id}`;
  }
  used.add(key);
  return key;
}

/**

 * Internal cache key: `${baseURL}::sha256(apiKey)`. We hash the apiKey so

 * the key is safe to log / inspect via debugger without leaking the secret.

 * Different (baseURL, apiKey) tuples MUST keep independent cache entries:

 * a single OC user may register prod + preprod OmniRoute side-by-side with

 * distinct keys, and serving one's catalog from the other's cache would be

 * a correctness bug, not just a privacy one.

 */
// codeql[js/insufficient-password-hash]: the input here is an API-key
// identifier we use solely to derive an in-memory cache lookup key β€” it is
// never stored, transmitted, compared against a hash, or used as a password.
// SHA-256 is intentional: cheap + deterministic, prevents the raw secret
// from sitting in memory dumps alongside the cache map. Slow KDFs (bcrypt/
// argon2) would defeat the purpose (sub-ms lookups on every request).
function modelsCacheKey(baseURL: string, credentialId: string): string {
  const h = createHash("sha256").update(credentialId).digest("hex");
  return `${baseURL}::${h}`;
}

/**

 * Shared fetch-result cache entry. Holds the RAW `/v1/models` + `/api/combos`

 * responses (NOT a pre-derived ModelV2 / static-entry shape) so the provider

 * hook (T-03/T-05) and the config-shim hook (T-07) can derive their own

 * output shapes from the same source without re-fetching.

 *

 * Why raw instead of derived:

 *   - provider hook emits ModelV2 (rich nested capabilities + cost + limits).

 *   - config hook emits the stripped sibling shape

 *     (`{name, attachment, reasoning, tool_call, temperature, limit?}`).

 *   - These overlap but neither is a superset of the other (ModelV2 has no

 *     `tool_call` field β€” it's `toolcall`; the stripped shape has no

 *     `cost`/`status`/`headers`). Caching the raw responses is the only

 *     lossless option.

 *   - On OC β‰₯1.14.49 cold start BOTH hooks fire within the same

 *     OmniRoutePlugin instance β€” sharing the cache means /v1/models +

 *     /api/combos each hit the gateway exactly ONCE per TTL refresh, not

 *     twice.

 */
export interface OmniRouteFetchCacheEntry {
  rawModels: OmniRouteRawModelEntry[];
  rawCombos: OmniRouteRawCombo[];
  /** Display-name + pricing overlay from /api/pricing/models. Empty Map when feature is disabled or fetch failed. */
  rawEnrichment: OmniRouteEnrichmentMap;
  /** Compression combos from /api/context/combos. Empty array when feature is disabled or fetch failed. */
  rawCompressionCombos: OmniRouteCompressionCombo[];
  /** Provider connections from /api/providers. Empty array when feature is disabled or fetch failed. */
  rawConnections: OmniRouteProviderConnection[];
  expiresAt: number;
}

export type OmniRouteFetchCache = Map<string, OmniRouteFetchCacheEntry>;

/**

 * Build the ProviderHook portion of the plugin for a given options bag.

 * Exported standalone so the contract is unit-testable without faking the

 * full PluginInput / Hooks surface, and so multi-instance setups can each

 * own their own cache (a fresh hook closure per plugin tuple).

 *

 * Behavioural contract:

 *   - `id` binds to the resolved `providerId` (multi-instance: each plugin

 *     tuple's hook lists models under its own provider id).

 *   - `models(provider, ctx)` extracts the api key from `ctx.auth` (rejecting

 *     non-`api` flavors with `{}` β€” same posture as the auth loader); calls

 *     both `/v1/models` and `/api/combos` fetchers; maps raw `/v1/models`

 *     entries through `mapRawModelToModelV2`; maps each `/api/combos` entry

 *     through `mapComboToModelV2` (LCD across its member models); merges

 *     combos into the same map under their combo id; caches the unified

 *     result by `(baseURL, sha256(apiKey))` for `modelCacheTtl`.

 *   - **Combo / model ID collisions: combos win.** OmniRoute treats combos

 *     as the curated routing surface; if a combo and a raw model share an

 *     id the operator's intent is clearly the combo. We emit a

 *     `console.warn` exactly once per `(baseURL, apiKey, comboId)`

 *     collision so the operator can spot the unusual naming choice

 *     without log spam on every cache refresh.

 *   - **Combos fetch failure does NOT break the catalog**: soft-fail with

 *     a `console.warn` and fall back to a models-only catalog. Rationale:

 *     `/api/combos` requires a management-scoped key and OmniRoute may

 *     not have any combos provisioned (preprod returned `{combos: []}`

 *     at probe time). Hard-failing the entire catalog when combos are

 *     optional would silently hide the whole provider from OC's model

 *     picker.

 *   - **`/v1/models` fetch failure DOES propagate.** Without models

 *     there's no catalog at all, so an empty `{}` would just mask the

 *     error.

 *   - Cache is in-memory per hook instance, shared between models and

 *     combos (one fetch pair per (baseURL, apiKey) per TTL refresh).

 *

 * @param opts Plugin options (providerId, baseURL, modelCacheTtl, …).

 * @param deps Dependency injection. `fetcher` defaults to the live

 *             `/v1/models` HTTP fetcher; `combosFetcher` defaults to the

 *             live `/api/combos` HTTP fetcher (override for tests / to

 *             disable combos by injecting one that returns `[]`). `now`

 *             defaults to `Date.now` (overridable for TTL tests). `cache`

 *             lets the caller share state across reconstructions (unused

 *             outside tests today).

 */
export function createOmniRouteProviderHook(

  opts?: OmniRoutePluginOptions,

  deps: {

    fetcher?: OmniRouteModelsFetcher;

    combosFetcher?: OmniRouteCombosFetcher;

    enrichmentFetcher?: OmniRouteEnrichmentFetcher;

    compressionMetaFetcher?: OmniRouteCompressionMetaFetcher;

    providersFetcher?: OmniRouteProvidersFetcher;

    now?: () => number;

    cache?: OmniRouteFetchCache;

  } = {}

): ProviderHook {
  const resolved = resolveOmniRoutePluginOptions(opts);
  const fetcher = deps.fetcher ?? defaultOmniRouteModelsFetcher;
  // T-05: combo discovery merges `/api/combos` entries into the same map as
  // `/v1/models`. Default fetcher is declared further down the file; the
  // reference resolves at hook-invocation time, not at hook-construction
  // time, so source-order beyond hoisting rules has no semantic effect.
  const combosFetcher = deps.combosFetcher ?? defaultOmniRouteCombosFetcher;
  const enrichmentFetcher = deps.enrichmentFetcher ?? defaultOmniRouteEnrichmentFetcher;
  const compressionMetaFetcher =
    deps.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher;
  const providersFetcher = deps.providersFetcher ?? defaultOmniRouteProvidersFetcher;
  // Features defaults (mirror v0.1.0 behavior when unset).
  const features = resolved.features ?? {};
  const wantCombos = features.combos !== false;
  const wantEnrichment = features.enrichment !== false;
  const wantCompressionMeta = features.compressionMetadata === true;
  const wantUsableOnly = features.usableOnly === true;
  const wantProviderTag = features.providerTag !== false;
  const now = deps.now ?? Date.now;
  // T-07: cache holds RAW fetch results (not pre-derived ModelV2) so that
  // the config-shim hook can share the same cache and derive its stripped
  // sibling shape from the same source without a second round-trip.
  const cache: OmniRouteFetchCache = deps.cache ?? new Map();
  // T-05: collision-warning deduper. Emit warn once per (cacheKey, comboId)
  // tuple per hook instance so the operator sees the unusual naming choice
  // once per session, not once per cache refresh.
  const collisionWarned = new Set<string>();

  return {
    id: resolved.providerId,
    async models(_provider, ctx) {
      // Auth narrowing β€” same posture as the auth loader (T-02). Non-api
      // flavors and empty keys β†’ empty catalog. OC then exposes the
      // /connect flow rather than spamming /v1/models with bad creds.
      const auth = ctx?.auth;
      if (
        !auth ||
        typeof auth !== "object" ||
        (auth as { type?: unknown }).type !== "api" ||
        typeof (auth as { key?: unknown }).key !== "string" ||
        (auth as { key: string }).key.length === 0
      ) {
        return {};
      }
      const apiKey = (auth as { key: string }).key;

      // baseURL resolution: plugin opts first, then credential-attached
      // baseURL (auth backends sometimes stash it next to the key). No
      // silent default to localhost: a misconfigured plugin should surface
      // a clear error, not phantom /v1/models calls. Cast through unknown
      // because the Auth union (OAuth | ApiAuth | WellKnownAuth) doesn't
      // declare baseURL on any branch β€” we duck-type it as a defensive
      // extension point.
      const authBaseURL = (auth as unknown as { baseURL?: unknown }).baseURL;
      const baseURL = resolved.baseURL ?? (typeof authBaseURL === "string" ? authBaseURL : "");
      if (!baseURL) {
        return {};
      }

      const cacheKey = modelsCacheKey(baseURL, apiKey);
      const t = now();
      const cached = cache.get(cacheKey);

      let rawModels: OmniRouteRawModelEntry[];
      let rawCombos: OmniRouteRawCombo[];
      let rawEnrichment: OmniRouteEnrichmentMap;
      let rawCompressionCombos: OmniRouteCompressionCombo[];
      let rawConnections: OmniRouteProviderConnection[];
      if (cached && cached.expiresAt > t) {
        rawModels = cached.rawModels;
        rawCombos = cached.rawCombos;
        rawEnrichment = cached.rawEnrichment;
        rawCompressionCombos = cached.rawCompressionCombos;
        rawConnections = cached.rawConnections;
      } else {
        // Models fetch is required (no catalog otherwise β†’ silent provider
        // disappearance). We do NOT wrap this in a try; let the error
        // propagate to OC's UI.
        rawModels = await fetcher(baseURL, apiKey, 10_000);

        // T-05: combos fetch is best-effort, gated by features.combos.
        // Soft-fail on any error: emit a console.warn and fall back to a
        // models-only catalog. Rationale: /api/combos requires a
        // management-scoped key and OmniRoute may not have any combos
        // provisioned. Hard-failing when combos are optional would
        // silently hide the whole provider from OC's picker.
        rawCombos = [];
        if (wantCombos) {
          try {
            rawCombos = await combosFetcher(baseURL, apiKey, 10_000);
          } catch (err) {
            console.warn(
              "[omniroute-plugin] combos fetch failed, falling back to models-only catalog",
              err
            );
          }
        }

        // Enrichment fetch (nice names + pricing). Best-effort, gated by
        // features.enrichment. Soft-fails to empty map.
        rawEnrichment = new Map();
        if (wantEnrichment) {
          try {
            rawEnrichment = await enrichmentFetcher(baseURL, apiKey, 10_000);
          } catch (err) {
            console.warn(
              "[omniroute-plugin] enrichment fetch failed, falling back to raw ids",
              err
            );
          }
        }

        // Compression metadata fetch. Off by default, gated by
        // features.compressionMetadata. Soft-fails to empty array.
        rawCompressionCombos = [];
        if (wantCompressionMeta) {
          try {
            rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000);
          } catch (err) {
            console.warn("[omniroute-plugin] compression-metadata fetch failed", err);
          }
        }

        // Provider-connections fetch. Off by default, gated by
        // features.usableOnly. Soft-fails to empty array β€” when the
        // connection table is unreadable we skip the filter entirely
        // (subtract-filter semantics: don't drop everything we couldn't
        // verify).
        rawConnections = [];
        if (wantUsableOnly) {
          try {
            rawConnections = await providersFetcher(baseURL, apiKey, 10_000);
          } catch (err) {
            console.warn(
              "[omniroute-plugin] /api/providers fetch failed; usableOnly filter disabled for this refresh",
              err
            );
          }
        }

        cache.set(cacheKey, {
          rawModels,
          rawCombos,
          rawEnrichment,
          rawCompressionCombos,
          rawConnections,
          expiresAt: t + resolved.modelCacheTtl,
        });

        // Debug breadcrumb: surface fetch result so operators can confirm
        // the dynamic pipeline fired and how much catalog OmniRoute returned.
        // Emitted once per cache miss (TTL refresh) β€” quiet on cache hits.
        console.warn(
          `[omniroute-plugin] catalog refreshed for providerId=${resolved.providerId} baseURL=${baseURL}: ` +
            `${rawModels.length} models + ${rawCombos.length} combos + ` +
            `${rawEnrichment.size} enrichment entries + ` +
            `${rawCompressionCombos.length} compression combos + ` +
            `${rawConnections.length} connections ` +
            `(TTL=${resolved.modelCacheTtl}ms)`
        );
      }

      // Lookup index for LCD member resolution: O(1) per member lookup.
      // Indexed by raw model `id` β€” combo steps reference this exact
      // string per ComboModelStep in src/lib/combos/steps.ts.
      const rawModelById = new Map<string, OmniRouteRawModelEntry>();
      for (const entry of rawModels) {
        if (entry.id) rawModelById.set(entry.id, entry);
      }

      // usableOnly filter β€” compute the set of usable alias prefixes once
      // per refresh. Empty when feature is off OR connection fetch failed
      // OR no connections returned, in which case we keep everything
      // (subtract-filter semantics: only drop when we can prove a prefix
      // is NOT usable; never hide the catalog on a soft-fail).
      const usable =
        wantUsableOnly && rawConnections.length > 0
          ? usableProviderAliasSet(rawConnections, rawEnrichment)
          : undefined;

      // Build the canonical→alias reverse map AND the canonical-dedup
      // set once per refresh. Together they fix the dual-keyed
      // `/v1/models` problem where the same model surfaces under BOTH
      // `<alias>/<id>` (enriched) AND `<canonical>/<id>` (raw): we keep
      // the alias key and skip the canonical twin entirely.
      const canonicalToAlias = buildCanonicalToAliasMap(rawEnrichment);
      const canonicalDedup = canonicalDedupSet(rawModels, canonicalToAlias);
      const aliasIndex = buildAliasIndex(rawEnrichment);

      // Map raw models β†’ ModelV2 keyed by id. When enrichment data is
      // present (features.enrichment, default on), overlay the nicer
      // display name + pricing from /api/pricing/models via the
      // alias-fallback lookup chain (covers canonical rows lacking
      // direct pricing entries).
      const models: Record<string, ModelV2> = {};
      for (const entry of rawModels) {
        if (!entry.id) continue;
        if (canonicalDedup.has(entry.id)) continue;
        if (usable && !isUsableRawModelId(entry.id, usable, rawEnrichment)) continue;
        const model = mapRawModelToModelV2(entry, {
          providerId: resolved.providerId,
          baseURL,
        });
        const enrichEntry = lookupEnrichment(entry.id, rawEnrichment, canonicalToAlias);
        applyEnrichment(model, enrichEntry);
        // Prepend upstream provider label (e.g. `Claude - Claude Opus 4.7`)
        // so the picker groups same-model rows by upstream connection.
        // Idempotent + gated by `features.providerTag` (default-on).
        // Combos skip this on purpose. The alias-index fallback rescues
        // raw rows like `cohere/rerank-multilingual-v3.0` whose specific
        // model id isn't in `/api/pricing/models` but whose slot is.
        if (wantProviderTag) {
          const tagEntry = resolveProviderTagEntry(
            entry.id,
            enrichEntry,
            aliasIndex,
            canonicalToAlias
          );
          applyProviderTag(model, tagEntry);
        }
        models[entry.id] = model;
      }

      // Default compression combo (used to decorate ALL combo names when
      // compression metadata is present). OmniRoute returns at most one
      // entry with `isDefault: true` per /api/context/combos.
      const defaultCompression = wantCompressionMeta
        ? rawCompressionCombos.find((c) => c.isDefault === true)
        : undefined;

      // T-05: map raw combos β†’ ModelV2. Skip hidden combos (operator
      // preference β€” provisioned but intentionally not surfaced).
      // Resolve each combo's member step list into the matching raw
      // model entries; unknown member ids are silently dropped before
      // mapComboToModelV2 sees them, which then degrades to the
      // all-false LCD posture if zero members remain.
      //
      // Combos are keyed under the `combo/<slug>` namespace so the TUI
      // picker separates them from provider/model pairs and the UUID
      // never surfaces. This mirrors `buildStaticProviderEntry` so the
      // static + dynamic catalogs publish identical keys.
      const comboNames = new Set<string>();
      for (const combo of rawCombos) {
        if (!combo || combo.isHidden === true) continue;
        const n = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id;
        if (typeof n === "string" && n.length > 0) comboNames.add(n);
      }
      for (const key of Object.keys(models)) {
        if (comboNames.has(key)) delete models[key];
      }

      const usedComboKeys = new Set<string>();
      for (const combo of rawCombos) {
        if (!combo.id) continue;
        if (combo.isHidden === true) continue;
        // usableOnly filter β€” drop combos whose members all map to
        // non-usable providers.
        if (usable && !isUsableCombo(combo, usable)) continue;

        const memberSteps = Array.isArray(combo.models) ? combo.models : [];
        const memberEntries: OmniRouteRawModelEntry[] = [];
        for (const step of memberSteps) {
          // Use the unknown-bridge pattern from commit 91b137e6 so the
          // DTS pass stays clean: ComboMemberRef declares `model?: string`
          // but we still verify the runtime shape before consuming it.
          const modelId = (step as unknown as { model?: unknown }).model;
          if (typeof modelId !== "string" || modelId.length === 0) continue;
          const member = rawModelById.get(modelId);
          if (member) memberEntries.push(member);
        }

        const mapped = mapComboToModelV2(combo, memberEntries, resolved.providerId, baseURL);
        const hasMembers = memberEntries.length > 0;

        // Apply enrichment overlay to combos too (OmniRoute's
        // /api/pricing/models surfaces combos alongside provider-scoped
        // models with curated names).
        applyEnrichment(mapped, rawEnrichment.get(combo.id));

        // `Combo: ` prefix surfaces the combo nature in OC's model picker.
        // Idempotent guard covers the case where enrichment overwrote
        // mapped.name with an already-prefixed string. Mirrors the
        // static-hook Combo:-prefix decoration.
        if (!mapped.name.startsWith("Combo: ")) {
          mapped.name = `Combo: ${mapped.name}`;
        }

        // Optionally decorate combo name with its compression pipeline.
        // Only fires when features.compressionMetadata: true, OmniRoute
        // returned at least one default compression combo, AND the
        // combo has resolvable members β€” claiming compression on an
        // unroutable combo would mislead the picker.
        if (hasMembers && defaultCompression && defaultCompression.pipeline.length > 0) {
          const tag = formatCompressionPipeline(defaultCompression.pipeline);
          if (tag.length > 0 && !mapped.name.includes(tag)) {
            mapped.name = `${mapped.name} ${tag}`;
          }
        }

        const comboKey = buildComboKey(combo, usedComboKeys);

        // Collision policy: combos win. Warn ONCE per (cacheKey, comboKey)
        // when overwriting a same-key raw model so the operator can spot
        // the unusual naming choice without log spam.
        if (Object.prototype.hasOwnProperty.call(models, comboKey)) {
          const dedupeKey = `${cacheKey}::${comboKey}`;
          if (!collisionWarned.has(dedupeKey)) {
            collisionWarned.add(dedupeKey);
            console.warn(
              `[omniroute-plugin] combo key "${comboKey}" collides with a model id; combo wins.`
            );
          }
        }
        models[comboKey] = mapped;
      }

      return models;
    },
  };
}

// ────────────────────────────────────────────────────────────────────────────
// Fetch interceptor (T-04) β€” Bearer + Content-Type injection on outbound
// provider requests targeting the configured OmniRoute baseURL
// ────────────────────────────────────────────────────────────────────────────

/**

 * Build a `fetch`-compatible interceptor that injects `Authorization: Bearer`

 * (and a default `Content-Type`) onto outbound requests targeting the given

 * `baseURL`. Requests to any other host pass through untouched β€” the apiKey

 * is treated as a secret bound to the configured OmniRoute instance and

 * MUST NOT leak to third-party endpoints (a vector AI-SDKs occasionally

 * exercise when a tool call rewrites the URL mid-flight).

 *

 * Ported from Alph4d0g's `opencode-omniroute-auth@1.2.1` `createFetchInterceptor`

 * (their `dist/src/plugin.js:477-516`) with these intentional deviations:

 *

 *   - **`baseURL` is required** here (no `localhost:20128/v1` fallback). T-04

 *     callers always have an authoritative baseURL (from plugin opts or

 *     auth.json); a silent local default would be a footgun.

 *   - **Content-Type defaulting is gated on `init.body` presence**. Their

 *     version unconditionally sets `application/json` even on `GET /v1/models`,

 *     which is harmless but noisy; we only set it when there's a body to

 *     describe.

 *   - **Gemini schema sanitisation is NOT applied here** β€” that's T-06's

 *     responsibility and will land as a body-transform step inside this

 *     same function (or as a thin wrapper around it).

 *   - **Header merge strategy mirrors theirs**: Request-attached headers

 *     first, then `init.headers` overlay, then our injected

 *     Authorization/Content-Type β€” so the apiKey we own ALWAYS wins over

 *     any caller-supplied Bearer for the same OmniRoute provider.

 *

 * @see https://opencode.ai/docs/plugins for the AuthLoaderResult.fetch contract

 *      (the returned function is invoked by the AI-SDK in lieu of global fetch).

 */
export function createOmniRouteFetchInterceptor(config: {

  apiKey: string;

  baseURL: string;

}): typeof fetch {
  const trimmed = trimTrailingSlashes(config.baseURL);
  // Use `<base>/` for prefix matching to prevent suffix-spoof attacks
  // (e.g. baseURL `https://or.example.com/v1` should NOT match
  // `https://or.example.com/v1-attacker.evil/...`).
  const prefix = `${trimmed}/`;
  return async (input, init = {}) => {
    const url =
      typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;

    const targetsOmniRoute = url === trimmed || url.startsWith(prefix);
    if (!targetsOmniRoute) {
      return fetch(input, init);
    }

    // Merge order: Request-attached headers (when input is a Request) β†’
    // init.headers overlay β†’ our injected headers last (so we win).
    const headers = new Headers(input instanceof Request ? input.headers : undefined);
    if (init.headers) {
      const initHeaders = new Headers(init.headers);
      initHeaders.forEach((value, key) => {
        headers.set(key, value);
      });
    }

    headers.set("Authorization", `Bearer ${config.apiKey}`);
    // Only default Content-Type when the caller actually has a body AND
    // hasn't already declared the media type themselves.
    const hasBody = init.body != null || input instanceof Request;
    if (!headers.has("Content-Type") && hasBody) {
      headers.set("Content-Type", "application/json");
    }

    return fetch(input, { ...init, headers });
  };
}

// ────────────────────────────────────────────────────────────────────────────
// Gemini tool-schema sanitisation (T-06) β€” strip JSON-schema keywords that
// the Gemini API rejects from outbound chat-completion / responses bodies
// when the target model is a Gemini variant.
// ────────────────────────────────────────────────────────────────────────────

/**

 * JSON-Schema keywords that the Gemini API rejects when present anywhere in

 * a function-calling tool definition. Standard OpenAI / Anthropic clients

 * happily emit these (they're valid Draft-07 schema) but Gemini's tool

 * validator throws on them, breaking OmniRoute β†’ Gemini chains transparently.

 *

 * Source: behavioural reverse-engineering from Alph4d0g's

 * opencode-omniroute-auth@1.2.1 (dist/src/plugin.js:517).

 */
const GEMINI_SCHEMA_KEYS_TO_REMOVE = new Set(["$schema", "$ref", "ref", "additionalProperties"]);

function isRecord(v: unknown): v is Record<string, unknown> {
  return typeof v === "object" && v !== null && !Array.isArray(v);
}

/**

 * Recursively strip `GEMINI_SCHEMA_KEYS_TO_REMOVE` from an arbitrary

 * JSON-Schema-shaped record. Walks both the record's own properties and

 * any nested objects / arrays so deeply nested `properties.x.properties.y`

 * trees are reached without a separate traversal pass. Mutates in place

 * and reports whether any key was deleted so callers can skip a

 * `JSON.stringify` round-trip when nothing changed.

 */
function stripSchemaKeys(schema: Record<string, unknown>): boolean {
  let changed = false;
  for (const key of Object.keys(schema)) {
    if (GEMINI_SCHEMA_KEYS_TO_REMOVE.has(key)) {
      delete schema[key];
      changed = true;
      continue;
    }
    const value = schema[key];
    if (Array.isArray(value)) {
      for (const item of value) {
        if (isRecord(item)) {
          changed = stripSchemaKeys(item) || changed;
        }
      }
      continue;
    }
    if (isRecord(value)) {
      changed = stripSchemaKeys(value) || changed;
    }
  }
  return changed;
}

/**

 * Walk every tool definition in the payload and strip Gemini-incompatible

 * schema keywords. Handles both chat-completion shape

 * (`tools[].function.parameters`) and Responses-API shape

 * (`tools[].input_schema`), plus the Gemini-native `function_declaration`

 * variant some adapters use.

 *

 * Also strips top-level schema keywords from the payload itself β€” clients

 * occasionally attach a top-level `$schema` declaration when re-serialising

 * tool bundles, and Gemini rejects those too.

 */
function sanitizeToolSchemaContainer(payload: Record<string, unknown>): boolean {
  let changed = false;
  // Top-level keyword strip β€” covers payload-level `$schema` etc.
  for (const key of Object.keys(payload)) {
    if (GEMINI_SCHEMA_KEYS_TO_REMOVE.has(key)) {
      delete payload[key];
      changed = true;
    }
  }
  const tools = (payload as { tools?: unknown }).tools;
  if (!Array.isArray(tools)) {
    return changed;
  }
  for (const tool of tools) {
    if (!isRecord(tool)) continue;
    const fn = (tool as { function?: unknown }).function;
    if (isRecord(fn) && isRecord((fn as { parameters?: unknown }).parameters)) {
      changed = stripSchemaKeys(fn.parameters as Record<string, unknown>) || changed;
    }
    const fnDecl = (tool as { function_declaration?: unknown }).function_declaration;
    if (isRecord(fnDecl) && isRecord((fnDecl as { parameters?: unknown }).parameters)) {
      changed = stripSchemaKeys(fnDecl.parameters as Record<string, unknown>) || changed;
    }
    const inputSchema = (tool as { input_schema?: unknown }).input_schema;
    if (isRecord(inputSchema)) {
      changed = stripSchemaKeys(inputSchema) || changed;
    }
  }
  return changed;
}

/**

 * Pure function β€” recursively strip Gemini-incompatible JSON-Schema

 * keywords (`$schema`, `$ref`, `ref`, `additionalProperties`) from the

 * tool definitions on a chat-completions / responses payload.

 *

 * Walks:

 *   - `payload.tools[].function.parameters` (OpenAI chat-completions shape)

 *   - `payload.tools[].function_declaration.parameters` (Gemini-native shape

 *     some adapters round-trip)

 *   - `payload.tools[].input_schema` (Responses-API shape)

 *   - all `properties.<x>` (and `properties.<x>.properties.<y>`…) inside

 *     each container, recursing through nested objects and arrays.

 *   - top-level payload keys (some clients attach a payload-level `$schema`).

 *

 * Returns the cleaned payload. Does NOT mutate input β€” clones first via

 * `structuredClone` so callers can keep a reference to the original. If

 * the payload is not a record, or carries no tools and no top-level

 * stripped keys, returns a (still cloned) equivalent.

 *

 * Exported so the body-transform layer is unit-testable independent of the

 * fetch wrapper.

 */
export function sanitizeGeminiToolSchemas(payload: unknown): unknown {
  if (!isRecord(payload)) {
    // Non-record payloads (string, array, number, null) can't carry tool
    // schemas. Pass back the same value β€” there's nothing to clone-and-strip
    // and propagating the original keeps caller semantics simple.
    return payload;
  }
  // structuredClone is available in Node 18+; the package's engines field
  // already requires Node >=22.22.3 so we can rely on it without a
  // JSON round-trip fallback.
  const cloned = structuredClone(payload) as Record<string, unknown>;
  sanitizeToolSchemaContainer(cloned);
  return cloned;
}

/**

 * Detect whether a payload is bound for a Gemini model. Returns true if

 * `payload.model` is a string AND matches any known Gemini routing pattern:

 *

 *   - case-insensitive substring `gemini` (covers bare `gemini-1.5-pro`,

 *     `gemini-2.5-flash`, etc.)

 *   - `models/gemini-…` (Google Generative AI canonical id form)

 *   - `google-vertex/gemini-…` (OpenCode + AI-SDK Vertex routing prefix)

 *   - `gemini-cli/…` (real OmniRoute alias surfaced on b35 prod `/v1/models`)

 *

 * Liberal by design: a false positive (cleaning a payload that didn't

 * need cleaning) costs only a structuredClone + one walk; a false negative

 * breaks the whole chain by forwarding $schema/additionalProperties to

 * Gemini which throws 400 INVALID_ARGUMENT. The first three checks

 * collapse into the case-insensitive substring check, but they're

 * documented separately so future maintainers see the intent.

 *

 * Exported so callers and tests can probe detection independent of the

 * fetch wrapper.

 */
export function shouldSanitizeForGemini(payload: unknown): boolean {
  if (!isRecord(payload)) return false;
  const model = (payload as { model?: unknown }).model;
  if (typeof model !== "string") return false;
  return /gemini/i.test(model);
}

/**

 * Module-level latch so the streaming-body warning fires AT MOST once per

 * Node process. ReadableStream bodies can't be safely cloned + JSON-parsed

 * without consuming the stream (and re-creating a stream that survives both

 * read paths is non-trivial), so the sanitiser skips them β€” but we want

 * the operator to see one heads-up that schema stripping won't run on

 * those requests.

 */
let geminiStreamingWarningEmitted = false;

/**

 * Wrapper over an inner `fetch` that applies Gemini schema sanitisation to

 * outbound chat-completion / responses request bodies.

 *

 * Behaviour:

 *   - URL gate: only inspects requests whose URL path contains

 *     `/chat/completions` or `/responses` (lenient about prefix β€” works for

 *     `/v1/chat/completions`, `/openai/v1/chat/completions`, …).

 *   - Body extraction handles `string`, `Buffer` / `Uint8Array`,

 *     `URLSearchParams` (calls `.toString()`), `Blob` (`await .text()`),

 *     AND `Request` input where the body lives on the Request not init.

 *     `ReadableStream` bodies are skipped (see below).

 *   - Body must JSON.parse to a record; otherwise pass-through.

 *   - `shouldSanitizeForGemini` gates the actual transform β€” non-Gemini

 *     payloads pass through unchanged regardless of endpoint.

 *   - Fail-open: ANY error during extraction / parse / sanitise falls back

 *     to forwarding the original `(input, init)` to the inner fetch.

 *     Sanitisation is a best-effort guard, never a hard failure mode.

 *   - `ReadableStream` bodies β†’ skipped with a ONE-TIME `console.warn`.

 *     The Gemini-quirk only manifests with tool calls in the body, and

 *     OC streams plain text deltas; the operator should still know.

 *

 * @param inner The next fetch in the chain (typically the Bearer-injecting

 *              interceptor from `createOmniRouteFetchInterceptor`).

 */
export function createGeminiSanitizingFetch(inner: typeof fetch): typeof fetch {
  return async (input, init) => {
    try {
      const url =
        typeof input === "string"
          ? input
          : input instanceof URL
            ? input.toString()
            : input instanceof Request
              ? input.url
              : "";

      // URL gate β€” match the path substring with prefix tolerance.
      const targetsCompletions = url.includes("/chat/completions") || url.includes("/responses");
      if (!targetsCompletions) {
        return inner(input, init);
      }

      // Body extraction. Cover the body shapes the AI-SDK + adapter layer
      // actually emit; bail to pass-through on anything we can't read
      // synchronously without consuming a stream.
      let rawBody: string | undefined;
      const initBody = init?.body as unknown;

      if (typeof initBody === "string") {
        rawBody = initBody;
      } else if (initBody instanceof URLSearchParams) {
        // Form-encoded bodies are never chat-completion JSON; pass-through.
        return inner(input, init);
      } else if (typeof Buffer !== "undefined" && initBody instanceof Buffer) {
        rawBody = initBody.toString("utf8");
      } else if (initBody instanceof Uint8Array) {
        rawBody = new TextDecoder().decode(initBody);
      } else if (initBody instanceof ReadableStream) {
        // Streaming body β€” skip with one-shot warning.
        if (!geminiStreamingWarningEmitted) {
          geminiStreamingWarningEmitted = true;

          console.warn(
            "[omniroute-plugin] sanitizeGemini: streaming Request body, skipping schema strip (Gemini may reject)"
          );
        }
        return inner(input, init);
      } else if (
        initBody !== null &&
        initBody !== undefined &&
        typeof (initBody as { text?: unknown }).text === "function"
      ) {
        // Blob-like (has .text(): Promise<string>). Streaming was already
        // matched above β€” anything left with a `.text` method we can buffer.
        try {
          rawBody = await (initBody as { text(): Promise<string> }).text();
        } catch {
          return inner(input, init);
        }
      } else if (initBody === undefined && input instanceof Request) {
        // Body lives on the Request object itself, not init. Clone before
        // reading β€” consuming the original Request body would make it
        // unreadable downstream.
        try {
          rawBody = await (input as Request).clone().text();
        } catch {
          return inner(input, init);
        }
      }

      if (rawBody === undefined || rawBody.length === 0) {
        return inner(input, init);
      }

      let payload: unknown;
      try {
        payload = JSON.parse(rawBody);
      } catch {
        // Non-JSON body β†’ pass-through, never throw.
        return inner(input, init);
      }

      if (!shouldSanitizeForGemini(payload)) {
        return inner(input, init);
      }

      const cleaned = sanitizeGeminiToolSchemas(payload);
      const newBody = JSON.stringify(cleaned);
      // Cloning init: we need to replace `body` without mutating the caller's
      // init bag. If init was undefined (Request-input path), construct one.
      const newInit: RequestInit = { ...(init ?? {}), body: newBody };
      return inner(input, newInit);
    } catch {
      // Total fail-open β€” never let a sanitiser bug break the request path.
      return inner(input, init);
    }
  };
}

/**

 * Test-only hook: reset the module-level streaming-warning latch so each

 * test can independently assert the one-shot semantics. Not part of the

 * public stability contract β€” prefixed with `__` per convention to signal

 * "do not depend on this from production code".

 */
export function __resetGeminiStreamingWarning(): void {
  geminiStreamingWarningEmitted = false;
}

// ────────────────────────────────────────────────────────────────────────────
// Config hook (T-07) β€” backward-compat shim for OC ≀1.14.48
//
// OC ≀1.14.48 does NOT call `provider.models()` at startup; it reads the
// catalog from the static `provider.<id>` config block instead. OC β‰₯1.14.49
// calls `provider.models()` dynamically AND merges the dynamic catalog over
// any static block (dynamic wins on collision). To support both, the plugin
// publishes a static block via `config` AND a dynamic one via `provider.models`
// β€” OC's resolution order picks the right one per OC version. This module
// implements the static-publish half.
//
// Sibling shape source-of-truth: see
// `@omniroute/opencode-provider/src/index.ts` (`createOmniRouteProvider`,
// `OpenCodeProviderEntry`, `OpenCodeModelEntry`). We replicate that shape
// here rather than depending on the sibling package β€” the plugin must stay
// self-contained (npm-installable on its own, no peer dep on the provider
// builder).
// ────────────────────────────────────────────────────────────────────────────

/**

 * Per-model entry shape under `provider.<id>.models[modelId]`. Mirrors

 * `OpenCodeModelEntry` exported by `@omniroute/opencode-provider`. Stripped

 * down to the fields OC's static catalog reader actually consumes β€” NOT a

 * full ModelV2 (that's the dynamic-hook shape). Optional fields are omitted

 * when OmniRoute didn't surface a value, NOT emitted as `undefined` β€” the

 * resulting JSON must be diffable across OmniRoute deployments without

 * `undefined` noise.

 */
/** Modalities accepted by OC's static catalog reader (see `@opencode-ai/sdk`). */
export type OmniRouteModalityKind = "text" | "audio" | "image" | "video" | "pdf";

const STATIC_MODALITY_VALUES: ReadonlySet<OmniRouteModalityKind> = new Set([
  "text",
  "audio",
  "image",
  "video",
  "pdf",
]);

/** Normalise + filter raw modality list to the values OC accepts. Deduped. */
function normaliseModalities(raw: unknown): OmniRouteModalityKind[] {
  if (!Array.isArray(raw)) return [];
  const out: OmniRouteModalityKind[] = [];
  const seen = new Set<string>();
  for (const v of raw) {
    if (typeof v !== "string") continue;
    const lower = v.toLowerCase() as OmniRouteModalityKind;
    if (!STATIC_MODALITY_VALUES.has(lower)) continue;
    if (seen.has(lower)) continue;
    seen.add(lower);
    out.push(lower);
  }
  return out;
}

export interface OmniRouteStaticModelEntry {
  /** Display label rendered in OC's model picker. Defaults to the model id. */
  name: string;
  /** ISO date the model was released. Surfaces in OC's model card when present. */
  release_date?: string;
  /** Model accepts image / file attachments. */
  attachment?: boolean;
  /** Model exposes a reasoning / extended-thinking surface. */
  reasoning?: boolean;
  /** Model honours the `temperature` parameter. */
  temperature?: boolean;
  /** Model supports function / tool calling. */
  tool_call?: boolean;
  /**

   * Per-million-token cost. Maps from OmniRoute `/api/pricing` shape:

   * `input`/`output` pass through; `cached` β†’ `cache_read`;

   * `cache_creation` β†’ `cache_write`. Omitted when no pricing slot resolves.

   */
  cost?: {
    input: number;
    output: number;
    cache_read?: number;
    cache_write?: number;
  };
  /**

   * Context-window limits. OC's static reader requires both `context` AND

   * `output` when `limit` is present, so the field is only emitted when

   * BOTH are known.

   */
  limit?: {
    context: number;
    output: number;
  };
  /**

   * Modality lists the model accepts (input) and emits (output). Maps from

   * OmniRoute's `input_modalities` / `output_modalities` on `/v1/models`.

   * Emitted only when at least one modality is known β€” without this field

   * OC's runtime catalog defaults `input.image: false` even when the model

   * card has `attachment: true`, which blocks clipboard image paste in the

   * TUI for vision-capable models.

   */
  modalities?: {
    input: OmniRouteModalityKind[];
    output: OmniRouteModalityKind[];
  };
}

/**

 * Static `provider.<id>` block written to `input.provider` by the config hook.

 * Mirrors `OpenCodeProviderEntry` from `@omniroute/opencode-provider`.

 *

 *   - `npm` is always `"@ai-sdk/openai-compatible"` β€” OmniRoute exposes an

 *     OpenAI-compatible surface and that's the AI-SDK adapter that speaks it.

 *   - `options.baseURL` MUST be the fully-qualified `/v1` URL (the AI-SDK

 *     appends paths like `/chat/completions` directly under it).

 *   - `options.apiKey` is the bearer token; the fetch interceptor (T-04)

 *     also injects it on the dynamic path, but the static block needs it

 *     embedded too so OC ≀1.14.48 can construct the SDK client without

 *     going through the auth hook.

 */
export interface OmniRouteStaticProviderEntry {
  npm: "@ai-sdk/openai-compatible";
  name: string;
  options: {
    baseURL: string;
    apiKey: string;
  };
  models: Record<string, OmniRouteStaticModelEntry>;
}

/**

 * Build the static `provider.<id>` block from raw `/v1/models` + `/api/combos`

 * responses. Pure function β€” no I/O, no side effects, no dependency on the

 * sibling provider package. Exported so callers and tests can construct the

 * block independently of the auth.json + fetch pipeline.

 *

 * Mapping rules (per the sibling `createOmniRouteProvider` output spec):

 *

 *   - One entry per raw model AND one entry per non-hidden combo.

 *   - `name` = model id (no separate display name on `/v1/models`).

 *   - `attachment` = `caps.attachment ?? caps.vision ?? false` β€” same

 *     convention as `mapRawModelToModelV2` (T-03).

 *   - `reasoning` = `caps.reasoning || caps.thinking`. Booleans only β€” we

 *     do NOT emit the field when both source flags are absent (keeps the

 *     stripped shape minimal).

 *   - `temperature` = `caps.temperature ?? true` β€” OpenAI-compat surface

 *     supports temperature by default; only an explicit `false` suppresses.

 *   - `tool_call` = `caps.tool_calling ?? false`.

 *   - `limit.context` = raw `context_length` when > 0; omitted otherwise.

 *   - `limit.input` = raw `max_input_tokens` when present.

 *   - `limit.output` = raw `max_output_tokens` when present.

 *

 * For combos: LCD across member raw models (matches `mapComboToModelV2`):

 *

 *   - `attachment`, `reasoning`, `tool_call`, `temperature`: `every` member.

 *   - `limit.context` = min(member context_lengths).

 *   - `limit.input` = min(member max_input_tokens) ONLY when every member

 *     declares one.

 *   - `limit.output` = min(member max_output_tokens).

 *   - Empty members β†’ all-false / limits omitted.

 *

 * Collision: combos win (matches the dynamic provider hook).

 *

 * @param rawModels Raw `/v1/models` entries (may be empty).

 * @param rawCombos Raw `/api/combos` entries (may be empty).

 * @param opts      Resolved plugin options (we read `displayName` + `providerId`).

 * @param baseURL   Fully-qualified `/v1` base URL β€” written verbatim to

 *                  `options.baseURL`. Caller is responsible for `/v1`

 *                  normalisation; we do NOT touch it here.

 * @param apiKey    Bearer token β€” written verbatim to `options.apiKey`.

 */
export function buildStaticProviderEntry(

  rawModels: OmniRouteRawModelEntry[],

  rawCombos: OmniRouteRawCombo[],

  opts: ReturnType<typeof resolveOmniRoutePluginOptions>,

  baseURL: string,

  apiKey: string,

  enrichment?: OmniRouteEnrichmentMap,

  compressionCombos?: OmniRouteCompressionCombo[],

  connections?: OmniRouteProviderConnection[]

): OmniRouteStaticProviderEntry {
  const models: Record<string, OmniRouteStaticModelEntry> = {};

  // usableOnly filter β€” compute once when feature enabled AND we have
  // connection data to filter against. Soft-fail (empty connections list)
  // disables the filter rather than hiding the catalog.
  const wantUsableOnly = opts.features?.usableOnly === true;
  const usable =
    wantUsableOnly && connections && connections.length > 0
      ? usableProviderAliasSet(connections, enrichment)
      : undefined;
  // Provider-tag suffix β€” default-on, opt-out via `features.providerTag: false`.
  // Prepends e.g. `Claude - ` to enriched raw-model names so the picker
  // can tell `cc/claude-opus-4-7` (Anthropic) apart from `kr/claude-opus-4-7`
  // (Kiro). Combos skip this by design.
  const wantProviderTag = opts.features?.providerTag !== false;

  // Build a name-set of every non-hidden combo from `/api/combos`. OmniRoute
  // pre-mirrors combos into `/v1/models` with the friendly name as the raw
  // id (e.g. `claude-primary`, `gemini-pro`), so without dedup the static
  // catalog ends up with both `claude-primary` (raw, opaque) AND the same
  // combo under `combo/claude-primary` (rich LCD). We suppress the raw twin
  // so each combo surfaces exactly once, under the `combo/` namespace.
  const comboNames = new Set<string>();
  for (const combo of rawCombos) {
    if (!combo || combo.isHidden === true) continue;
    const name = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id;
    if (typeof name === "string" && name.length > 0) comboNames.add(name);
  }

  // Build the canonical→alias reverse map AND the canonical-dedup set
  // once per static-block construction. Same shape as the dynamic hook
  // so both catalogs publish identical keys (no `claude/X` raw twin
  // shadowing the enriched `cc/X` row).
  const canonicalToAlias = buildCanonicalToAliasMap(enrichment);
  const canonicalDedup = canonicalDedupSet(rawModels, canonicalToAlias);
  const aliasIndex = buildAliasIndex(enrichment);

  // Raw model entries β†’ stripped per-model shape.
  for (const raw of rawModels) {
    if (!raw.id) continue;
    // Skip the 20 named no-slash entries that shadow combos under the
    // `combo/<name>` namespace. We keep `codex-auto-review` and any other
    // future no-slash raw entry that doesn't have a matching combo.
    if (comboNames.has(raw.id)) continue;
    // Skip canonical-named twins when the alias-keyed enriched row exists.
    if (canonicalDedup.has(raw.id)) continue;
    if (usable && !isUsableRawModelId(raw.id, usable, enrichment)) continue;
    const caps = raw.capabilities ?? {};
    // Enrichment overlay: `/api/pricing/models` carries human display names
    // (e.g. "Claude Opus 4.7" for raw id "cc/claude-opus-4-7"). The OC TUI
    // model picker reads this `name` straight from the static block on
    // OC ≀1.15.5 where the dynamic provider hook never fires. Falls back
    // to the raw id when no enrichment entry is found. The alias-fallback
    // lookup rescues `<canonical>/<id>` rows whose enrichment indexed only
    // under `<alias>/<id>`.
    const enrichmentEntry = lookupEnrichment(raw.id, enrichment, canonicalToAlias);
    const enrichmentName = enrichmentEntry?.name;
    let displayName = enrichmentName && enrichmentName.length > 0 ? enrichmentName : raw.id;
    // Provider-tag PREFIX β€” `<label> - <name>` so the picker groups by
    // upstream provider when scanning a column of model names. Mirrors
    // `applyProviderTag` used in the dynamic hook. Idempotent: skip
    // when the name already starts with the prefix. The alias-index
    // fallback rescues raw rows like `cohere/rerank-multilingual-v3.0`
    // whose specific model id isn't in `/api/pricing/models` but whose
    // slot is.
    if (wantProviderTag) {
      const tagEntry = resolveProviderTagEntry(
        raw.id,
        enrichmentEntry,
        aliasIndex,
        canonicalToAlias
      );
      const label = shortProviderLabel(tagEntry);
      if (label) {
        const prefix = `${label}${PROVIDER_TAG_SEPARATOR}`;
        if (!displayName.startsWith(prefix)) displayName = `${prefix}${displayName}`;
      }
    }
    const entry: OmniRouteStaticModelEntry = { name: displayName };

    const attachment = caps.attachment ?? caps.vision;
    if (typeof attachment === "boolean") entry.attachment = attachment;

    if (typeof caps.reasoning === "boolean" || typeof caps.thinking === "boolean") {
      entry.reasoning = Boolean(caps.reasoning || caps.thinking);
    }

    if (typeof caps.temperature === "boolean") {
      entry.temperature = caps.temperature;
    }

    if (typeof caps.tool_calling === "boolean") {
      entry.tool_call = caps.tool_calling;
    }

    // OC's SDK schema requires BOTH `context` and `output` when `limit` is
    // present. We previously emitted `limit.input` too, but the SDK reader
    // doesn't accept it β€” drop it. Only emit `limit` when both required
    // values are known.
    if (
      typeof raw.context_length === "number" &&
      raw.context_length > 0 &&
      typeof raw.max_output_tokens === "number" &&
      raw.max_output_tokens > 0
    ) {
      entry.limit = {
        context: raw.context_length,
        output: raw.max_output_tokens,
      };
    }

    // Modalities β€” emit when OmniRoute surfaced any. Without this field
    // OC's runtime model defaults `input.image: false` even for vision-
    // capable models, blocking clipboard image paste in the TUI.
    const inModalities = normaliseModalities(raw.input_modalities);
    const outModalities = normaliseModalities(raw.output_modalities);
    if (inModalities.length > 0 || outModalities.length > 0) {
      entry.modalities = {
        input: inModalities.length > 0 ? inModalities : ["text"],
        output: outModalities.length > 0 ? outModalities : ["text"],
      };
    }

    // Cost from enrichment pricing (sourced from `/api/pricing`). Map
    // OmniRoute field names to OC's static-schema field names.
    const pricing = enrichmentEntry?.pricing;
    if (pricing && (typeof pricing.input === "number" || typeof pricing.output === "number")) {
      const cost: NonNullable<OmniRouteStaticModelEntry["cost"]> = {
        input: typeof pricing.input === "number" ? pricing.input : 0,
        output: typeof pricing.output === "number" ? pricing.output : 0,
      };
      if (typeof pricing.cacheRead === "number") cost.cache_read = pricing.cacheRead;
      if (typeof pricing.cacheWrite === "number") cost.cache_write = pricing.cacheWrite;
      entry.cost = cost;
    }

    // release_date from /v1/models β€” surfaces in OC's model card when present.
    if (typeof raw.release_date === "string" && raw.release_date.length > 0) {
      entry.release_date = raw.release_date;
    }

    models[raw.id] = entry;
  }

  // Combo entries β†’ stripped LCD shape. Each combo is keyed as
  // `combo/<friendly-name>` so the OC TUI model picker shows them under a
  // distinct namespace (e.g. `combo/claude-primary`) instead of the opaque
  // upstream UUID id (e.g. `b4a0211e-e3e1-472d-b252-fb9bf6d1c935`).
  const rawModelById = new Map<string, OmniRouteRawModelEntry>();
  for (const m of rawModels) {
    if (m.id) rawModelById.set(m.id, m);
  }

  // Resolve the default compression pipeline once β€” its short signature
  // (e.g. `[rtk:standard β†’ caveman:full]`) is appended to every routable
  // combo `name` so operators can see what compression a combo applies
  // at a glance. Provider hook does the same decoration when feature is
  // on. Suffix is suppressed for combos with no resolvable members β€”
  // claiming compression on an unroutable combo would mislead the
  // picker.
  let compressionSuffix = "";
  if (compressionCombos && compressionCombos.length > 0) {
    const def = compressionCombos.find((c) => c.isDefault === true);
    if (def) {
      const sig = formatCompressionPipeline(def.pipeline);
      if (sig.length > 0) compressionSuffix = ` ${sig}`;
    }
  }

  // Track combo keys to detect slug collisions across the catalog.
  const usedComboKeys = new Set<string>();

  for (const combo of rawCombos) {
    if (!combo.id) continue;
    if (combo.isHidden === true) continue;
    if (usable && !isUsableCombo(combo, usable)) continue;

    const memberSteps = Array.isArray(combo.models) ? combo.models : [];
    const memberEntries: OmniRouteRawModelEntry[] = [];
    for (const step of memberSteps) {
      const modelId = (step as unknown as { model?: unknown }).model;
      if (typeof modelId !== "string" || modelId.length === 0) continue;
      const member = rawModelById.get(modelId);
      if (member) memberEntries.push(member);
    }

    const hasMembers = memberEntries.length > 0;
    const friendlyName = combo.name && combo.name.trim().length > 0 ? combo.name.trim() : combo.id;
    // `Combo: ` prefix surfaces the combo nature in OC's model picker β€” the
    // catalog key (`combo/<slug>`) is already namespaced, but the picker
    // shows `name`, so prefix the display string too.
    const prefixedName = `Combo: ${friendlyName}`;
    const displayName =
      hasMembers && compressionSuffix ? `${prefixedName}${compressionSuffix}` : prefixedName;
    const entry: OmniRouteStaticModelEntry = { name: displayName };

    if (hasMembers) {
      // LCD across capabilities β€” every member must support for the combo
      // to support. Mirrors mapComboToModelV2.
      entry.attachment = memberEntries.every((m) =>
        Boolean(m.capabilities?.attachment ?? m.capabilities?.vision ?? false)
      );
      entry.reasoning = memberEntries.every((m) =>
        Boolean(m.capabilities?.reasoning || m.capabilities?.thinking)
      );
      entry.temperature = memberEntries.every(
        (m) => (m.capabilities?.temperature ?? true) !== false
      );
      entry.tool_call = memberEntries.every((m) => Boolean(m.capabilities?.tool_calling ?? false));

      // LCD across limits β€” min over declared values. OC's SDK static schema
      // accepts only `context` + `output` on `limit`, so we drop the legacy
      // `input` emission. Emit only when BOTH context AND output are known
      // across at least one member (mirrors the required-field constraint).
      const contextValues = memberEntries
        .map((m) => m.context_length)
        .filter((v): v is number => typeof v === "number" && v > 0);
      const outputValues = memberEntries
        .map((m) => m.max_output_tokens)
        .filter((v): v is number => typeof v === "number" && v > 0);

      if (contextValues.length > 0 && outputValues.length > 0) {
        entry.limit = {
          context: Math.min(...contextValues),
          output: Math.min(...outputValues),
        };
      }

      // LCD across modalities β€” combo accepts modality M iff every member
      // accepts M. Same intersection rule as runtime capabilities.
      const inSets = memberEntries.map((m) => new Set(normaliseModalities(m.input_modalities)));
      const outSets = memberEntries.map((m) => new Set(normaliseModalities(m.output_modalities)));
      const intersect = (sets: Set<OmniRouteModalityKind>[]): OmniRouteModalityKind[] => {
        if (sets.length === 0) return [];
        const [first, ...rest] = sets;
        const out: OmniRouteModalityKind[] = [];
        for (const v of first) {
          if (rest.every((s) => s.has(v))) out.push(v);
        }
        return out;
      };
      const inModalities = intersect(inSets);
      const outModalities = intersect(outSets);
      if (inModalities.length > 0 || outModalities.length > 0) {
        entry.modalities = {
          input: inModalities.length > 0 ? inModalities : ["text"],
          output: outModalities.length > 0 ? outModalities : ["text"],
        };
      }
    } else {
      // Empty members β†’ safety posture: all caps false. Caller's OC picker
      // will grey out an unroutable combo rather than promise capabilities
      // we can't honour.
      entry.attachment = false;
      entry.reasoning = false;
      entry.temperature = false;
      entry.tool_call = false;
    }

    // Key under `combo/<slug>` (e.g. `combo/claude-primary`) so the
    // namespace cleanly separates combos from raw provider/model pairs
    // and so the key is copy/paste-friendly. Slug collisions across
    // combos are disambiguated with a short UUID-prefix suffix; see
    // `buildComboKey` for the policy.
    models[buildComboKey(combo, usedComboKeys)] = entry;
  }

  return {
    npm: "@ai-sdk/openai-compatible",
    name: opts.displayName,
    options: { baseURL, apiKey },
    models,
  };
}

/**

 * Shape we expect inside `auth.json`. The file is keyed by providerId, with

 * each entry being a flavor-tagged credential. Today only the `api` flavor

 * is consumed by this plugin (OAuth + WellKnown flavors are passed through

 * but never decoded into a static block).

 */
interface AuthJsonApiEntry {
  type: "api";
  key: string;
  baseURL?: string;
}

type AuthJsonShape = Record<string, AuthJsonApiEntry | { type?: string; [k: string]: unknown }>;

/**

 * Read & parse `auth.json` from OC's data dir. The path resolution mirrors

 * OC core's:

 *

 *   `${OPENCODE_DATA_DIR ?? path.join(os.homedir(), ".local/share/opencode")}/auth.json`

 *

 * Returns `undefined` when the file is missing (most-common case on a fresh

 * install β€” silent no-op). Returns `null` when the file exists but doesn't

 * parse as JSON (logs ONE warn so the operator sees the corruption).

 *

 * Exported as a dependency-injectable function on `createOmniRouteConfigHook`

 * so tests can stub it without monkey-patching `node:fs/promises`.

 */
// ─────────────────────────────────────────────────────────────────────────
// Disk-cache fallback. Persists the last successful raw-fetch snapshot to
// `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`.
// When `/v1/models` is unreachable (e.g. IP whitelist drop, offline laptop)
// AND the in-memory cache is cold, the config hook reads from disk so the
// last-known catalog still surfaces in OC's model picker. Feature-flagged:
// `features.diskCache !== false` (default-on).
// ─────────────────────────────────────────────────────────────────────────

/** Disk snapshot envelope. Versioned for forward-compat. */
interface OmniRouteDiskSnapshot {
  v: 1;
  rawModels: OmniRouteRawModelEntry[];
  rawCombos: OmniRouteRawCombo[];
  /** Serialised as array-of-pairs (Map is not JSON-friendly). */
  rawEnrichment: Array<[string, OmniRouteEnrichmentEntry]>;
  rawCompressionCombos: OmniRouteCompressionCombo[];
  rawConnections: OmniRouteProviderConnection[];
  /** When the snapshot was written (epoch ms). */
  writtenAt: number;
}

/** Resolve the disk-snapshot path for a given providerId. */
export function diskSnapshotPath(providerId: string): string {
  const dir = process.env.OPENCODE_DATA_DIR ?? path.join(os.homedir(), ".local/share/opencode");
  return path.join(dir, "plugins", `omniroute-${providerId}.json`);
}

export type OmniRouteDiskSnapshotWriter = (

  providerId: string,

  entry: Omit<OmniRouteFetchCacheEntry, "expiresAt">

) => Promise<void>;

export type OmniRouteDiskSnapshotReader = (

  providerId: string

) => Promise<Omit<OmniRouteFetchCacheEntry, "expiresAt"> | undefined>;

/** Best-effort disk write. Soft-fails on any I/O error (no exception thrown). */
export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async (providerId, entry) => {
  try {
    const file = diskSnapshotPath(providerId);
    // Restrict perms to the owner: the snapshot lives alongside auth.json
    // (0o600) and embeds provider topology + masked connection records.
    await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
    const snapshot: OmniRouteDiskSnapshot = {
      v: 1,
      rawModels: entry.rawModels,
      rawCombos: entry.rawCombos,
      rawEnrichment: Array.from(entry.rawEnrichment.entries()),
      rawCompressionCombos: entry.rawCompressionCombos,
      rawConnections: entry.rawConnections,
      writtenAt: Date.now(),
    };
    await writeFile(file, JSON.stringify(snapshot), { encoding: "utf8", mode: 0o600 });
  } catch {
    // Soft-fail; caller already has the in-memory cache.
  }
};

/** Best-effort disk read. Returns `undefined` when missing/corrupt/unreadable. */
export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async (providerId) => {
  try {
    const file = diskSnapshotPath(providerId);
    const body = await readFile(file, "utf8");
    const parsed = JSON.parse(body) as Partial<OmniRouteDiskSnapshot>;
    if (!parsed || parsed.v !== 1) return undefined;
    return {
      rawModels: Array.isArray(parsed.rawModels) ? parsed.rawModels : [],
      rawCombos: Array.isArray(parsed.rawCombos) ? parsed.rawCombos : [],
      rawEnrichment: new Map(Array.isArray(parsed.rawEnrichment) ? parsed.rawEnrichment : []),
      rawCompressionCombos: Array.isArray(parsed.rawCompressionCombos)
        ? parsed.rawCompressionCombos
        : [],
      rawConnections: Array.isArray(parsed.rawConnections) ? parsed.rawConnections : [],
    };
  } catch {
    return undefined;
  }
};

/** No-op disk-cache pair β€” used by tests to avoid filesystem side effects. */
export const noopDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
export const noopDiskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;

export type OmniRouteReadAuthJson = () => Promise<AuthJsonShape | undefined | null>;

export const defaultReadAuthJson: OmniRouteReadAuthJson = async () => {
  const dir = process.env.OPENCODE_DATA_DIR ?? path.join(os.homedir(), ".local/share/opencode");
  const file = path.join(dir, "auth.json");
  let body: string;
  try {
    body = await readFile(file, "utf8");
  } catch {
    // File missing or unreadable β€” silent no-op. This is the expected path
    // on a fresh install BEFORE `/connect` has been run.
    return undefined;
  }
  try {
    const parsed = JSON.parse(body) as unknown;
    if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
      return parsed as AuthJsonShape;
    }
    return null;
  } catch {
    return null;
  }
};

/**

 * Build the config-hook portion of the plugin for a given options bag.

 * Exported standalone so the contract is unit-testable without faking the

 * full PluginInput / Hooks surface, and so multi-instance setups can each

 * own their own (auth.json reader, fetch cache, fetcher) trio.

 *

 * Behavioural contract:

 *   - Runs BEFORE `auth.loader` in the OC startup sequence (per the

 *     @opencode-ai/plugin contract). `getAuth()` is NOT available here,

 *     so we read `auth.json` directly via the injected reader.

 *   - No-op when:

 *       (a) `auth.json` is missing / unreadable (fresh install before

 *           `/connect`),

 *       (b) `auth.json[providerId]` is missing or not type-api,

 *       (c) `apiKey` is empty after extraction,

 *       (d) `baseURL` is unresolvable (neither opts.baseURL nor

 *           `auth.json[providerId].baseURL`),

 *       (e) `input.provider[providerId]` is ALREADY set (operator override

 *           wins β€” we never clobber manually-curated catalogs).

 *     Each no-op path emits ONE debug-level breadcrumb to `console.warn`

 *     so the operator can diagnose without log spam. Malformed `auth.json`

 *     warns once and continues as if the file were missing.

 *   - Fail-open on fetcher errors: a `/v1/models` failure β†’ still publish

 *     a stub `{models: {}}` provider block (so OC has a complete-shape

 *     entry to render). A `/api/combos` failure β†’ publish models-only.

 *     Both paths emit ONE `console.warn`.

 *   - When the provider hook (T-03/T-05) has ALREADY populated the shared

 *     cache for this (baseURL, apiKey) tuple, we reuse the raw payloads

 *     directly β€” no second fetch. (And vice-versa: the config hook fires

 *     first on OC β‰₯1.14.49 cold start, populating the cache for the

 *     provider hook moments later.)

 *   - DUAL-PUBLISH SAFE: on OC β‰₯1.14.49 BOTH this static block and the

 *     dynamic `provider.models()` result will land in OC's catalog

 *     reducer. The dynamic block wins by OC's own merge rule β€” see

 *     OpenCode core's provider resolution order β€” so emitting both is a

 *     correctness-positive: ≀1.14.48 reads static, β‰₯1.14.49 prefers

 *     dynamic but the static one keeps things responsive during the

 *     ~50ms window before the dynamic fetch resolves.

 *

 * @param opts Plugin options (validated, resolved with defaults).

 * @param deps Dependency injection.

 *   - `readAuthJson`     β€” replaces `defaultReadAuthJson` (test stub).

 *   - `fetcher`          β€” replaces `defaultOmniRouteModelsFetcher`.

 *   - `combosFetcher`    β€” replaces `defaultOmniRouteCombosFetcher`.

 *   - `now`              β€” clock for cache TTL (default `Date.now`).

 *   - `cache`            β€” shared fetch-result cache (see

 *                          `OmniRouteFetchCache`). Pass the same Map the

 *                          provider hook owns to dedupe round-trips.

 *   - `logger`           β€” `{warn}` sink for breadcrumb capture in tests.

 *                          Defaults to `console`.

 */
export function createOmniRouteConfigHook(

  opts?: OmniRoutePluginOptions,

  deps: {

    readAuthJson?: OmniRouteReadAuthJson;

    fetcher?: OmniRouteModelsFetcher;

    combosFetcher?: OmniRouteCombosFetcher;

    enrichmentFetcher?: OmniRouteEnrichmentFetcher;

    compressionMetaFetcher?: OmniRouteCompressionMetaFetcher;

    providersFetcher?: OmniRouteProvidersFetcher;

    diskSnapshotReader?: OmniRouteDiskSnapshotReader;

    diskSnapshotWriter?: OmniRouteDiskSnapshotWriter;

    now?: () => number;

    cache?: OmniRouteFetchCache;

    logger?: { warn: (...args: unknown[]) => void };

  } = {}

): (input: Config) => Promise<void> {
  const resolved = resolveOmniRoutePluginOptions(opts);
  const readAuthJson = deps.readAuthJson ?? defaultReadAuthJson;
  const fetcher = deps.fetcher ?? defaultOmniRouteModelsFetcher;
  const combosFetcher = deps.combosFetcher ?? defaultOmniRouteCombosFetcher;
  const enrichmentFetcher = deps.enrichmentFetcher ?? defaultOmniRouteEnrichmentFetcher;
  const compressionMetaFetcher =
    deps.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher;
  const providersFetcher = deps.providersFetcher ?? defaultOmniRouteProvidersFetcher;
  const diskSnapshotReader = deps.diskSnapshotReader ?? defaultDiskSnapshotReader;
  const diskSnapshotWriter = deps.diskSnapshotWriter ?? defaultDiskSnapshotWriter;
  const now = deps.now ?? Date.now;
  const cache: OmniRouteFetchCache = deps.cache ?? new Map();
  const logger = deps.logger ?? console;
  const features = resolved.features ?? {};
  const wantEnrichment = features.enrichment !== false;
  const wantCompressionMeta = features.compressionMetadata === true;
  const wantUsableOnly = features.usableOnly === true;
  const wantDiskCache = features.diskCache !== false;
  const wantProviderTag = features.providerTag !== false;

  return async (input: Config) => {
    // (e) operator override β€” `input.provider[providerId]` already set β†’
    // leave it alone. Manually curated catalogs ALWAYS win over the plugin's
    // generated block. Detect-and-respect before any I/O.
    const existingProviders = (input as { provider?: Record<string, unknown> }).provider;
    if (existingProviders && existingProviders[resolved.providerId] !== undefined) {
      logger.warn(
        `[omniroute-plugin] config shim skipped: provider.${resolved.providerId} already set by user`
      );
      return;
    }

    // Read auth.json. `undefined` = missing file (silent path), `null` =
    // malformed JSON (warn once and treat as missing).
    let authJson: AuthJsonShape | undefined | null;
    try {
      authJson = await readAuthJson();
    } catch {
      // Reader threw β€” be conservative and treat like a missing file.
      authJson = undefined;
    }

    if (authJson === null) {
      logger.warn("[omniroute-plugin] config shim: auth.json failed to parse; treating as missing");
      authJson = undefined;
    }

    const entry = authJson?.[resolved.providerId] as AuthJsonApiEntry | undefined;
    const apiKey = entry && entry.type === "api" && typeof entry.key === "string" ? entry.key : "";

    if (!apiKey) {
      // (c) no apiKey β€” silent no-op (with debug breadcrumb). The operator
      // hasn't run `/connect <providerId>` yet, OR the stored credential
      // isn't api-flavored. OC will handle the `/connect` flow at runtime.
      logger.warn(
        `[omniroute-plugin] config shim skipped: no apiKey for providerId=${resolved.providerId}`
      );
      return;
    }

    // baseURL resolution: opts.baseURL wins, then auth.json's stored baseURL.
    // No silent localhost default β€” a misconfigured plugin should surface a
    // breadcrumb and skip, not phantom requests.
    const storedBaseURL = entry && typeof entry.baseURL === "string" ? entry.baseURL : undefined;
    const baseURL = resolved.baseURL ?? storedBaseURL ?? "";
    if (!baseURL) {
      logger.warn(
        `[omniroute-plugin] config shim skipped: no baseURL for providerId=${resolved.providerId}`
      );
      return;
    }

    // Try the shared cache first. On OC β‰₯1.14.49 the provider hook may have
    // populated it moments earlier; on OC ≀1.14.48 only this hook runs but
    // the cache still works (single producer + consumer through one Map).
    const cacheKey = modelsCacheKey(baseURL, apiKey);
    const t = now();
    const cached = cache.get(cacheKey);

    let rawModels: OmniRouteRawModelEntry[];
    let rawCombos: OmniRouteRawCombo[];
    let rawEnrichment: OmniRouteEnrichmentMap;
    let rawCompressionCombos: OmniRouteCompressionCombo[];
    let rawConnections: OmniRouteProviderConnection[];

    if (cached && cached.expiresAt > t) {
      rawModels = cached.rawModels;
      rawCombos = cached.rawCombos;
      rawEnrichment = cached.rawEnrichment;
      rawCompressionCombos = cached.rawCompressionCombos;
      rawConnections = cached.rawConnections;
    } else {
      // Fail-open fetcher errors: on /v1/models throw, fall back to empty
      // catalog (still publish a stub block so OC has a complete-shape
      // entry); on /api/combos throw, publish models-only. Disk-cache
      // fallback below recovers the last-known-good catalog when the
      // fetcher threw (network down / 403 / timeout) AND features.diskCache
      // !== false. A 0-entry SUCCESS (fresh tenant) does NOT trigger
      // disk fallback β€” that's a valid empty catalog.
      let modelsFetchThrew = false;
      try {
        rawModels = await fetcher(baseURL, apiKey, 10_000);
      } catch (err) {
        logger.warn(
          "[omniroute-plugin] config shim: /v1/models fetch failed; publishing stub provider entry",
          err
        );
        rawModels = [];
        modelsFetchThrew = true;
      }
      const modelsFetchOk = !modelsFetchThrew && rawModels.length > 0;

      rawCombos = [];
      try {
        rawCombos = await combosFetcher(baseURL, apiKey, 10_000);
      } catch (err) {
        logger.warn(
          "[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog",
          err
        );
      }

      // Eagerly fetch enrichment so the static block can overlay human
      // display names on raw model ids. On OC ≀1.15.5 the dynamic
      // `provider.models` hook never fires in `serve` mode, so the static
      // block IS what reaches `/provider` and the TUI model picker.
      // Gated by `features.enrichment` (default-on). Soft-fail on error β€”
      // we still publish a name-less catalog if /api/pricing/models is
      // unreachable.
      rawEnrichment = new Map();
      if (wantEnrichment) {
        try {
          rawEnrichment = await enrichmentFetcher(baseURL, apiKey, 10_000);
        } catch (err) {
          logger.warn(
            "[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog",
            err
          );
        }
      }

      // Compression-metadata fetch β€” opt-in via features.compressionMetadata.
      // When on, the default pipeline is appended to every combo `name` so
      // the TUI picker advertises which compression a combo applies.
      rawCompressionCombos = [];
      if (wantCompressionMeta) {
        try {
          rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000);
        } catch (err) {
          logger.warn(
            "[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix",
            err
          );
        }
      }

      // Provider-connections fetch β€” opt-in via features.usableOnly. When
      // on, the static catalog filters out models/combos whose canonical
      // provider has no active connection. Soft-fail (empty list) disables
      // the filter for this refresh, never hiding the whole catalog.
      rawConnections = [];
      if (wantUsableOnly) {
        try {
          rawConnections = await providersFetcher(baseURL, apiKey, 10_000);
        } catch (err) {
          logger.warn(
            "[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh",
            err
          );
        }
      }

      // Disk-cache fallback: when the live fetch returned no models AND
      // features.diskCache !== false, hydrate from the last-known-good
      // snapshot so OC still surfaces a usable catalog (e.g. IP whitelist
      // drop, offline laptop). The snapshot is whatever we last wrote on
      // a healthy refresh; staleness is bounded only by how recently the
      // user was online.
      if (modelsFetchThrew && wantDiskCache) {
        const snapshot = await diskSnapshotReader(resolved.providerId);
        if (snapshot && snapshot.rawModels.length > 0) {
          logger.warn(
            `[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
          );
          rawModels = snapshot.rawModels;
          rawCombos = snapshot.rawCombos;
          rawEnrichment = snapshot.rawEnrichment;
          rawCompressionCombos = snapshot.rawCompressionCombos;
          rawConnections = snapshot.rawConnections;
        }
      }

      // Cache even partial results β€” a subsequent provider-hook call should
      // not re-burn the timeout window on the same broken endpoint.
      cache.set(cacheKey, {
        rawModels,
        rawCombos,
        rawEnrichment,
        rawCompressionCombos,
        rawConnections,
        expiresAt: t + resolved.modelCacheTtl,
      });

      // Disk-cache write: persist the last successful (or any non-empty)
      // catalog so a subsequent cold start with a failed fetch can recover.
      // Best-effort; soft-fail keeps us moving when the data dir isn't
      // writable (e.g. read-only container).
      if (modelsFetchOk && wantDiskCache) {
        await diskSnapshotWriter(resolved.providerId, {
          rawModels,
          rawCombos,
          rawEnrichment,
          rawCompressionCombos,
          rawConnections,
        });
      }
    }

    const block = buildStaticProviderEntry(
      rawModels,
      rawCombos,
      resolved,
      baseURL,
      apiKey,
      rawEnrichment,
      rawCompressionCombos,
      rawConnections
    );

    // Mutate the input.provider map. The Config type declares
    // `provider?: {[key: string]: ProviderConfig}` β€” we initialise the
    // bag when absent so users who never set `provider` in opencode.json
    // still get the static block.
    const inputWithProvider = input as { provider?: Record<string, unknown> };
    if (!inputWithProvider.provider) {
      inputWithProvider.provider = {};
    }
    inputWithProvider.provider[resolved.providerId] = block;

    // ─────────────────────────────────────────────────────────────────────
    // MCP auto-emit β€” opt-in via features.mcpAutoEmit. When enabled, writes
    // an `input.mcp[<providerId>]` remote entry pointing at
    // `<baseURL>/api/mcp/stream` with the resolved Bearer token. Token
    // resolution: features.mcpToken wins if set; otherwise falls back to
    // the same apiKey used for chat. Operator overrides win (same posture
    // as provider-block emit): if input.mcp[providerId] is already set,
    // we leave it alone.
    // ─────────────────────────────────────────────────────────────────────
    if (features.mcpAutoEmit === true) {
      const mcpKey = features.mcpToken ?? apiKey;
      if (!mcpKey) {
        logger.warn(
          `[omniroute-plugin] mcp auto-emit skipped: no Bearer token for providerId=${resolved.providerId}`
        );
      } else {
        const inputWithMcp = input as { mcp?: Record<string, unknown> };
        if (!inputWithMcp.mcp) {
          inputWithMcp.mcp = {};
        }
        if (inputWithMcp.mcp[resolved.providerId] !== undefined) {
          logger.warn(
            `[omniroute-plugin] mcp auto-emit skipped: mcp.${resolved.providerId} already set by user`
          );
        } else {
          // Strip a trailing `/v1` from baseURL when present so we land on
          // the MCP transport at /api/mcp/stream, not /v1/api/mcp/stream.
          const mcpRoot = baseURL.replace(/\/v1\/?$/, "").replace(/\/$/, "");
          inputWithMcp.mcp[resolved.providerId] = {
            type: "remote",
            url: `${mcpRoot}/api/mcp/stream`,
            enabled: true,
            headers: {
              Authorization: `Bearer ${mcpKey}`,
            },
          };
        }
      }
    }
  };
}