File size: 88,306 Bytes
dcedb25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { nextMonthResetAt } from '../services/key-budget.js';
// One shared provider retry/fallback loop for every OpenAI-, Responses- and
// Anthropic-shaped chat surface (routes/proxy.ts legacy /completions and
// /chat/completions, routes/responses.ts, routes/anthropic.ts). Each surface
// used to carry its own ~150-line copy of the attempt loop, and the copies had
// drifted (a 403 that was benched for a day on three surfaces but only 90s on
// /v1/responses; an exhaustion body that returned a 400 for a provider-invalid
// request on three surfaces but always 429 on /v1/messages; a Retry-After that
// was honored everywhere except /v1/responses). This module is the single
// source of truth for the parts that MUST behave identically β€” cooldown
// selection, per-key failure bookkeeping, exhaustion status β€” while each
// surface keeps its own request/stream translation as a thin `dispatch` adapter.
//
// Almost pure control-flow + accounting: no Express, no wire-format knowledge.
// The per-surface bytes (SSE framing, error-body shape, context handoff, group
// routing) live in the caller's hooks. The one side effect beyond bookkeeping is
// the fire-and-forget key revalidation kicked off on an upstream 401 (below).

import type { RouteResult } from '../services/router.js';
import { recordRateLimitHit, recordModelFailure, recordSuccess, hasOtherUsableKey, routableKeyIdsForModel, formatResetEta } from '../services/router.js';
import { safeHeaderValue } from './header-value.js';
import {
  recordRequest,
  recordTokens,
  setCooldown,
  getActiveCooldownsForKeys,
  getCooldownDecisionForLimit,
  getSoonestCooldownExpiry,
  getPaymentRequiredCooldownMs,
  getModelForbiddenCooldownMs,
  learnLimitFromError,
  type CooldownDecision,
  type CooldownSource,
} from '../services/ratelimit.js';
import {
  isRetryableError,
  isRateLimitSignal,
  isKeyAuthError,
  isClientAbortError,
  isHedgeAbortError,
  isDailyQuotaExhaustedError,
  isPaymentRequiredError,
  isModelNotFoundError,
  isModelAccessForbiddenError,
  isAccountSuspendedError,
  isProviderBadRequestError,
  isProviderDegradedError,
  isProviderLevelError,
  isContextTooLargeError,
  isTimeoutErrorText,
  isStreamTruncatedError,
} from './error-classify.js';
import { sanitizeProviderErrorMessage, summarizeAttemptError } from './error-redaction.js';
import { benchForTools, clearToolRejections, noteToolRejection, toolCapabilityKey } from './tool-capability.js';
import { parseProviderReportedSize } from './provider-size-parser.js';
import { checkKeyHealth, markKeyHealthyFromRequest } from '../services/health.js';
import { noteModelRetirementSignal } from '../services/model-retirement.js';
import { getDb, getSetting } from '../db/index.js';
import { newBreaker, recordBreakerFailure } from './guardrails.js';
import { getRequestTrace, newRequestTrace, runWithRequestTrace, type AttemptOutcome, type AttemptTraceRecord, type RequestTrace } from './attempt-trace.js';
import { logRequest, persistRequestAttempts } from './request-log.js';
import { withKeyProxy } from './proxy.js';
import { getEndpointTimeBudgetMs } from './ttfb-budget.js';
import { learnOutputCapFromError, learnedOutputCap } from './output-cap.js';

// Every surface caps failover hops at the same number.
export const FALLBACK_MAX_RETRIES = 20;

// ── Model-level failure benching ─────────────────────────────────────────────
// A model that keeps failing upstream (401/429/5xx/empty-stream/timeout) must
// not keep being picked by auto-routing: every dead-end attempt wastes seconds
// of user-visible latency. The per-key cooldown above benches the *key*; this
// is the model-level counterpart β€” a sliding window of failures *across keys*
// that benches the whole MODEL once the streak is convincing. The window counts
// across keys, so the bench must span keys too: benching only the key that
// happened to fail last leaves every sibling key serving the same sick model
// until each one trips its own streak. Recovery stays automatic: the bench is
// 'heuristic', the only source the cooldown-probe job may clear early, and it
// re-validates each benched key once half the bench has been served (the probe
// exercises the credential, not the model, so a model still sick behind a good
// key simply re-trips the streak). Client-cancels never reach
// recordRetryableFailure (the loop returns on isClientAbortError before this
// bookkeeping), so they don't count.
export const MODEL_FAILURE_WINDOW_MS = 15 * 60 * 1000;   // sliding window: 15 min
export const MODEL_FAILURE_THRESHOLD = 3;         // failures within window
export const MODEL_FAILURE_COOLDOWN_MS = 10 * 60 * 1000; // bench duration: 10 min
const modelFailureTimestamps = new Map<number, number[]>(); // model_db_id β†’ times

/** Record one retryable upstream failure for a model. Once the sliding window
 *  holds β‰₯ MODEL_FAILURE_THRESHOLD failures, bench the model on EVERY key that
 *  can route to it so the model sinks out of routing until upstream heals, then
 *  reset the counter (one bench per streak). */
function noteModelFailure(route: RouteResult, now: number): void {
  const window = (modelFailureTimestamps.get(route.modelDbId) ?? [])
    .filter(t => now - t < MODEL_FAILURE_WINDOW_MS);
  window.push(now);
  modelFailureTimestamps.set(route.modelDbId, window);
  if (window.length < MODEL_FAILURE_THRESHOLD) return;
  // Fall back to the failing key alone when the model's key set can't be read
  // (model row gone, DB unavailable) β€” a narrower bench beats none.
  const keyIds = routableKeyIdsForModel(route.modelDbId);
  const targets = keyIds.length > 0 ? keyIds : [route.keyId];
  const benchUntil = now + MODEL_FAILURE_COOLDOWN_MS;
  const active = getActiveCooldownsForKeys(targets, now);
  for (const keyId of targets) {
    // Never SHORTEN an existing bench: a provider-stated reset or an escalated
    // ladder step outlasting this window knows more than this heuristic does,
    // and overwriting it would also drop its non-probeable provenance.
    const current = active.get(keyId)
      ?.find(c => c.platform === route.platform && c.modelId === route.modelId);
    if (current && current.expiresAtMs >= benchUntil) continue;
    setCooldown(route.platform, route.modelId, keyId, MODEL_FAILURE_COOLDOWN_MS, 'heuristic');
  }
  modelFailureTimestamps.delete(route.modelDbId);
}

/** A served request is the strongest counter-evidence: clear the failure
 *  window so a recovered model is not benched for a stale streak. */
function clearModelFailure(route: RouteResult): void {
  modelFailureTimestamps.delete(route.modelDbId);
}

/** Bench one key on every enabled model of its platform it can route to (the
 *  route's own model is benched by the caller). For KEY-level verdicts only β€”
 *  an account suspension, an empty balance β€” never for a model-level one.
 *  Also rules the key out on those models for the rest of THIS request via
 *  state.skipKeys, so the loop's next hop cannot land on the same account.
 *  Never throws: an unreadable catalog leaves the narrower per-route bench in
 *  place.
 */
function benchKeyAcrossPlatform(
  route: RouteResult,
  durationMs: number,
  source: CooldownSource,
  state: FallbackState,
  reason: string,
): void {
  let modelIds: string[] = [];
  try {
    modelIds = (getDb().prepare(
      'SELECT model_id FROM models WHERE platform = ? AND enabled = 1 AND (key_id IS NULL OR key_id = ?)',
    ).all(route.platform, route.keyId) as { model_id: string }[]).map(r => r.model_id);
  } catch (dbErr: any) {
    console.warn(`[FallbackLoop] could not list ${route.platform} models to bench key ${route.keyId}: ${dbErr?.message ?? dbErr}`);
    return;
  }
  let benched = 0;
  for (const modelId of modelIds) {
    if (modelId === route.modelId) continue;
    setCooldown(route.platform, modelId, route.keyId, durationMs, source);
    state.skipKeys.add(`${route.platform}:${modelId}:${route.keyId}`);
    benched++;
  }
  console.warn(`[FallbackLoop] ${route.platform} key ${route.keyId} ${reason}; benched it on ${benched + 1} model(s) for ${Math.round(durationMs / 60_000)}min`);
}

// ── Wall-clock retry budget ──────────────────────────────────────────────────
// Serial failover has no time bound of its own: the observed worst case was a
// 38.8s TTFB over 11 attempts, and the theoretical worst is maxRetries x the
// per-attempt HTTP timeout. The budget is checked before STARTING each retry
// and, when abortInFlight is available, while waiting for its first byte.
// Successful endpoint TTFB history can widen the configured base budget.
// The first attempt always runs, and so does the FIRST retry: when
// attempt 0 alone consumes the whole budget (a slow-failing model), refusing
// attempt 1 would make failover structurally impossible for exactly the
// requests that need it (#751). The budget stops attempts >= 2 only.
// 0 disables the budget entirely.
// Precedence mirrors the response cache: the settings-table value wins when
// present (runtime-tunable), then the env var, then the default.
export const DEFAULT_FALLBACK_TIME_BUDGET_MS = 45_000;
// Share of the wall-clock budget an aborted attempt must have been silent for
// before the hedge abort counts as provider health rather than bad luck. An
// attempt late in a ladder inherits only the scraps of the budget its
// predecessors left, and killing it proves nothing about that route; one that
// owned most of the window and still sent no first byte is stalled. Kept a
// fraction rather than a constant because the budget IS the declared patience
// β€” raising FALLBACK_TIME_BUDGET_MS should raise the evidence bar with it.
export const HEDGE_BENCH_MIN_SILENT_FRACTION = 0.75;
export const FALLBACK_TIME_BUDGET_SETTING = 'fallback_time_budget_ms';

export function getFallbackTimeBudgetMs(): number {
  let stored: string | undefined;
  try {
    stored = getSetting(FALLBACK_TIME_BUDGET_SETTING);
  } catch {
    stored = undefined; // DB not ready β€” never throw on the proxy hot path
  }
  const candidates = [stored, process.env.FALLBACK_TIME_BUDGET_MS];
  for (const raw of candidates) {
    if (raw === undefined || raw.trim() === '') continue;
    const n = Number(raw);
    if (Number.isFinite(n) && n >= 0) return n;
  }
  return DEFAULT_FALLBACK_TIME_BUDGET_MS;
}

// Mutable per-request skip state threaded through the loop and mutated by
// recordRetryableFailure / recordAuthFailure. skipKeys entries are
// "platform:modelId:keyId"; skipModels holds model_db_ids ruled out for the
// rest of this request; skipPlatforms holds platforms ruled out wholesale
// (#788) β€” every model and every key of them β€” for the rest of this request.
// observedTotalTokens is the provider-reported REQUESTED size parsed from the
// first 413 / context-length body of this request, used to inflate the
// routing estimate on the next attempt so models whose tpm_limit /
// context_window can't fit it are skipped before they fire (#507). All four
// are request-scoped only: nothing here outlives the response, so a provider
// that blipped once is a fresh candidate on the very next request.
export interface FallbackState {
  skipKeys: Set<string>;
  skipModels: Set<number>;
  skipPlatforms: Set<string>;
  // Highest REQUESTED token count seen so far this request across all 413 /
  // context-length rejections. Max wins (never decreases): a smaller later
  // reading is the same request, not a smaller one.
  observedTotalTokens?: number;
  observedInputTokens?: number;
  // The request carries `tools` (#1230). Set by the surface right after
  // newFallbackState(); lets the failure/success paths learn which models
  // reject tool calls in practice. Unset means "not a tool request".
  wantsTools?: boolean;
  // Models (tool-capability keys) that answered provider_bad_request to THIS
  // tool request. Counted once per request however many keys were tried.
  toolRejects?: Set<string>;
  // Per-request tally of DISTINCT models that answered model_not_found, keyed
  // by platform (#1218): a stale catalog misses on every sibling model in a
  // row, and the per-model skip can't see the pattern. From
  // MODEL_NOT_FOUND_PLATFORM_LIMIT distinct misses the platform joins
  // skipPlatforms for the rest of the request.
  modelNotFoundPlatforms: Map<string, Set<number>>;
}

/** Total for the next dispatch. Input-only observations still need output space. */
export function fallbackRoutingTokens(state: FallbackState, estimatedTotal: number, outputReserve: number): number {
  return Math.max(
    estimatedTotal,
    state.observedTotalTokens ?? 0,
    state.observedInputTokens == null ? 0 : state.observedInputTokens + Math.max(0, outputReserve),
  );
}

export function newFallbackState(): FallbackState {
  return { skipKeys: new Set<string>(), skipModels: new Set<number>(), skipPlatforms: new Set<string>(), modelNotFoundPlatforms: new Map() };
}

// DISTINCT model_not_found hops on one platform, within one request, before the
// whole platform is ruled out: a healthy catalog never misses three different
// models in a single failover chain β€” a stale/broken one does it routinely
// (NavyAI: 24 models tried in one session, 0 ok, #1218). 3 keeps one fluke
// removal from condemning a provider.
export const MODEL_NOT_FOUND_PLATFORM_LIMIT = 3;

// Milliseconds until the next UTC midnight β€” when most providers' daily free
// allocations reset. Floored at one minute so a hit seconds before midnight
// still records a real bench instead of a no-op.
export function msUntilNextUtcMidnight(now = Date.now()): number {
  const d = new Date(now);
  const next = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1);
  return Math.max(next - now, 60_000);
}

const pacificDate = new Intl.DateTimeFormat('en-US', {
  timeZone: 'America/Los_Angeles', year: 'numeric', month: 'numeric', day: 'numeric',
});
const pacificHour = new Intl.DateTimeFormat('en-US', {
  timeZone: 'America/Los_Angeles', hour: 'numeric', hourCycle: 'h23',
});

/** Gemini daily quotas reset at midnight Pacific, including DST transitions.
 * https://ai.google.dev/gemini-api/docs/rate-limits */
export function msUntilNextPacificMidnight(now = Date.now()): number {
  const parts = pacificDate.formatToParts(now);
  const part = (name: string) => Number(parts.find(p => p.type === name)!.value);
  // 08:00 UTC on the next Pacific date is either midnight (PST) or 01:00
  // (PDT). Read the offset on that date, not now: DST days have 23/25 hours.
  const candidate = Date.UTC(part('year'), part('month') - 1, part('day') + 1, 8);
  const midnight = candidate - Number(pacificHour.format(candidate)) * 3_600_000;
  return Math.max(midnight - now, 60_000);
}

/**
 * The one true cooldown-duration selection after a retryable upstream failure:
 *   - 402 out-of-credits  β†’ a full day (PAYMENT_REQUIRED_COOLDOWN_MS)
 *   - 403 model-not-on-tier β†’ a full day (MODEL_FORBIDDEN_COOLDOWN_MS), because a
 *     tier/subscription gate won't clear on the next minute window (issue #256).
 *     Both honour the operator's cooldown ceiling (#952): relay endpoints emit
 *     these for transient reasons, and a day is then far too long.
 *   - a 429 that says the DAILY free allocation is spent (Cloudflare "used up
 *     your daily free allocation of 10,000 neurons") β†’ benched until the next
 *     UTC midnight, like the 402 path. The old transient 90s cooldown made the
 *     router re-pick a dead-for-the-day provider all day long. An explicit
 *     provider Retry-After wins over the midnight heuristic: rolling daily
 *     windows (Groq RPD "try again in 7m12s" with a Retry-After header) reset
 *     well before midnight, and the provider knows its own reset time best.
 *     Gemini daily violations instead wait until midnight Pacific; a shorter
 *     RetryInfo can refer to a simultaneous per-minute violation.
 *   - anything else β†’ the transient/daily escalation ladder, honoring the
 *     provider's Retry-After as a floor (getCooldownDurationForLimit).
 */
export function cooldownForError(route: RouteResult, err: any): number {
  return cooldownDecisionForError(route, err).durationMs;
}

/**
 * cooldownForError plus the provenance tag the cooldown-probe recovery job
 * keys off (see CooldownSource in services/ratelimit.ts): 402 β†’ 'credit' and
 * 403 β†’ 'tier' (a key-validation probe passing proves nothing about credits or
 * tier, so those are never probed); a daily-quota bench β†’ 'authoritative' (the
 * expiry is the provider's own reset time, whether from Retry-After or the
 * UTC-midnight convention β€” a fact, not a guess); everything else defers to
 * getCooldownDecisionForLimit, which tags 'authoritative' only when an explicit
 * Retry-After actually determined the expiry.
 */
export function cooldownDecisionForError(route: RouteResult, err: any): CooldownDecision {
  if (isPaymentRequiredError(err)) return { durationMs: getPaymentRequiredCooldownMs(), source: 'credit' };
  // Before the model-forbidden check: a suspended account is a 403 too, but
  // it is the KEY that is out, for as long as an empty balance would be β€” and
  // under the same operator ceiling (#952).
  if (isAccountSuspendedError(err)) return { durationMs: getPaymentRequiredCooldownMs(), source: 'credit' };
  if (isModelAccessForbiddenError(err)) return { durationMs: getModelForbiddenCooldownMs(), source: 'tier' };
  if (isDailyQuotaExhaustedError(err)) {
    if (route.platform === 'google') {
      // RetryInfo can describe a simultaneous per-minute violation. It cannot
      // reopen a daily allowance before the Pacific reset (#1339).
      return { durationMs: Math.max(msUntilNextPacificMidnight(), err?.retryAfterMs ?? 0), source: 'authoritative' };
    }
    return { durationMs: err?.retryAfterMs ?? msUntilNextUtcMidnight(), source: 'authoritative' };
  }
  return getCooldownDecisionForLimit(
    route.platform,
    route.modelId,
    route.keyId,
    { rpd: route.rpdLimit, tpd: route.tpdLimit },
    err?.retryAfterMs,
    // Only a real 429/rate-limit error may feed the null-limits exhaustion
    // heuristic; a timeout or 5xx is retryable but says nothing about quota,
    // so it stays on the short transient bench instead of the ladder (#592).
    { quotaSignal: isRateLimitSignal(err) },
  );
}

// ── Truncated-stream streak (#1218) ──────────────────────────────────────────
// A stream that answers 200, sends partial SSE, then dies without [DONE] /
// finish_reason ("stream ended unexpectedly") is retried normally β€” one
// truncation is common on flaky free gateways. But the same route producing
// them back-to-back is a sick edge: bench it briefly so the ladder stops
// re-paying the round trip. Success on the route resets the streak.
export const TRUNCATION_STREAK_LIMIT = 3;
// 5 min vs the ordinary 90s transient bench: the streak must add REAL distance
// over the default or it changes nothing β€” the escalation ladder already gives
// a single truncation 90s.
export const TRUNCATION_BENCH_MS = 5 * 60 * 1000;
const truncationStreaks = new Map<string, number>(); // "platform:modelId:keyId"

export function resetTruncationStreaks(): void {
  truncationStreaks.clear();
}

// ── Empty-completion streak (issue #751) ─────────────────────────────────────
// The skipBench exemption below assumes an empty 'length' completion is
// REQUEST behavior (this turn's max_tokens spent on hidden reasoning). A
// model+key that produces them consecutively is broken, not unlucky β€” with an
// unconditional exemption it stayed penalty-free forever while failing every
// request routed to it. At the streak limit the exemption lifts and the
// failure takes the normal cooldown/penalty/limit-learning path (and counts
// toward the breaker). Format violations (skipModelForRequest) never accrue:
// they are model behavior for THIS request's response_format, not key health.
export const EMPTY_COMPLETION_STREAK_LIMIT = 3;
const emptyCompletionStreaks = new Map<string, number>(); // "platform:modelId:keyId"

export function resetEmptyCompletionStreaks(): void {
  emptyCompletionStreaks.clear();
}

/** Drop every model's sliding failure window. Module state outlives a test
 *  case, so without this a case inherits the previous one's failure counts and
 *  trips the threshold early β€” clearing `rate_limit_cooldowns` alone does not
 *  reach it. */
export function resetModelFailureWindows(): void {
  modelFailureTimestamps.clear();
}

/** Operator clear (#952): drop every model's failure window and report how many
 *  models were mid-streak. The benches those streaks produced are ordinary
 *  cooldown rows and go with clearAllCooldowns. */
export function clearModelFailureWindows(): number {
  const count = modelFailureTimestamps.size;
  modelFailureTimestamps.clear();
  return count;
}

// Advance (or break) the streak for this failure and report whether the
// skipBench exemption still holds. Called exactly once per retryable failure,
// from recordRetryableFailure; the loop reuses its returned decision for the
// breaker so the two consumers can never disagree.
function consumeSkipBenchExemption(route: RouteResult, err: any): boolean {
  const key = `${route.platform}:${route.modelId}:${route.keyId}`;
  if (err?.skipBench !== true) {
    // A normally-penalized failure breaks the streak: the cooldown ladder is
    // already handling whatever is wrong with this model+key.
    emptyCompletionStreaks.delete(key);
    return false;
  }
  if (err?.skipModelForRequest === true) return true;
  const streak = (emptyCompletionStreaks.get(key) ?? 0) + 1;
  emptyCompletionStreaks.set(key, streak);
  return streak < EMPTY_COMPLETION_STREAK_LIMIT;
}

/**
 * Apply the full per-key failure bookkeeping shared by every surface after a
 * retryable failure:
 *   - rule out the WHOLE model for the rest of the request on a 404 (removed
 *     upstream) or 403 (off this key's tier) β€” a sibling key would fail it the
 *     same way (PR #111 / issue #256);
 *   - bench this model+key via cooldownForError;
 *   - demote the model in the scorer ONLY when the failure exhausted it β€” i.e.
 *     no sibling key can still serve it (#454 gate). skipKeys already contains
 *     the just-failed key here, preserving #479's "count budget across keys"
 *     semantics: hasOtherUsableKey excludes both the failed key and skipKeys;
 *   - learn a provider-reported ceiling (e.g. a Groq 413 "TPM: Limit 30000")
 *     from the error body so the next pre-check fails over before the 413.
 *
 * Reasoning-truncation exemption: an error thrown with `skipBench: true` (a
 * reasoning model that spent the whole max_tokens budget on hidden reasoning,
 * finish_reason 'length') still fails over β€” the key is skipped for THIS
 * request β€” but is NOT a provider-health signal, so no cooldown, no model
 * penalty, and no limit-learning are recorded. Benching those was costing
 * healthy models a 90s cooldown + a scorer penalty per truncated turn. The
 * exemption is streak-bounded (#751): from the EMPTY_COMPLETION_STREAK_LIMITth
 * consecutive empty completion on the same model+key it stops applying, until
 * a success (or a normally-penalized failure) resets the streak.
 *
 * Returns whether the skipBench exemption held for this failure, so the loop
 * can keep the breaker in lockstep with the bench decision.
 *
 * Callers add the just-failed key to skipKeys via this function (do not pre-add).
 */
export function recordRetryableFailure(route: RouteResult, err: any, state: FallbackState, now: number = Date.now()): boolean {
  // `skipModelForRequest: true` = the failure is MODEL behavior, not key
  // state (ignored response_format, JSON truncated at max_tokens): a sibling
  // key would reproduce it exactly, so rule out the whole model for this
  // request instead of burning one failover hop per key.
  // Context-too-large is MODEL-level too: a sibling key serves the same model
  // with the same context window (and, for Groq-style per-key TPM 413s, the
  // same tier ceiling), so it would reject the same request identically.
  if (isModelNotFoundError(err) || isModelAccessForbiddenError(err) || isContextTooLargeError(err) || err?.skipModelForRequest === true) {
    state.skipModels.add(route.modelDbId);
  }
  // A stale CATALOG (not a single dead model) answers model-not-found on every
  // sibling model in a row: NavyAI served 24 models over one session, 0 ok,
  // each re-paying a 2.5–10s round trip (#1218). The per-model skip above
  // can't see the pattern β€” each miss is a different model. Count DISTINCT
  // model_not_found hops per platform within one request; from the threshold,
  // rule the whole platform out for the rest of the request: the catalog (or
  // the key's access to it) is broken, and the next PROVIDER is the better
  // hop. Same request-scoped lifetime as skipModels (#111/#256 semantics).
  // Custom relays are exempt: every relay shares the one platform id 'custom'
  // (#651), so three misses spread over three different relays would rule out
  // every healthy relay too. Their misses stay per-model.
  if (isModelNotFoundError(err) && !route.endpointScope) {
    const seen = state.modelNotFoundPlatforms.get(route.platform) ?? new Set<number>();
    seen.add(route.modelDbId);
    state.modelNotFoundPlatforms.set(route.platform, seen);
    if (seen.size >= MODEL_NOT_FOUND_PLATFORM_LIMIT) {
      state.skipPlatforms.add(route.platform);
    }
  }
  // A model-level 404/410 that says the model is GONE (not merely missing right
  // now) outlives this request: persist it once the evidence is strong enough,
  // or the retired model burns a fallback slot on every request forever (#634).
  // The trace object identifies the request, so one request's failover across
  // sibling keys counts as the single observation it is.
  noteModelRetirementSignal(route, err, getRequestTrace());
  // Provider-reported request size (#507): if the just-failed attempt was a
  // size-related rejection that names the request's real token count, latch it
  // onto the request state. The route() closure on the next attempt picks it
  // up and feeds it as the routing estimate, so the existing tpm_limit /
  // context_window gates in router.ts skip low-ceiling models on retry
  // instead of letting them fire and 413 again. Only fires on a size-class
  // error β€” the parser is conservative and returns null for opaque bodies
  // (every non-supported platform, the bare "Request Entity Too Large"
  // Groq shape, and the github limit-only case), so behavior is unchanged
  // when there's nothing to latch. Max wins so a smaller later reading of
  // the same request can't drop the estimate.
  if (isContextTooLargeError(err) || isProviderBadRequestError(err)) {
    const reported = parseProviderReportedSize(route.platform, err?.message);
    if (reported != null) {
      const field = reported.kind === 'input' ? 'observedInputTokens' : 'observedTotalTokens';
      state[field] = Math.max(state[field] ?? 0, reported.tokens);
    }
  }
  state.skipKeys.add(`${route.platform}:${route.modelId}:${route.keyId}`);
  // #788: provider-level failures (5xx / timeout / transport / degraded) mean
  // the PROVIDER is sick, not this key β€” every key AND every model of that
  // platform would fail identically. Rule out the whole platform for this
  // request so the loop moves to the NEXT provider instead of burning one
  // failover hop per key. Key-scoped failures (auth/quota) stay on the
  // single-key path, and the per-key cooldown below is still the only thing
  // that outlives the request.
  if (isProviderLevelError(err)) {
    state.skipPlatforms.add(route.platform);
  }
  // Too big for this model is a fact about the REQUEST, not the model's
  // health: it still serves every smaller request. Skip it for this request
  // (above) and learn the reported ceiling so the router sizes it out next
  // time, but no cooldown and no penalty. Benching it let one oversized agent
  // turn (Claude Code ships ~16k tokens of tool schemas) sink every small-TPM
  // model it touched, so later ordinary requests found the pool rate limited.
  if (isContextTooLargeError(err)) {
    learnLimitFromError(route.modelDbId, err);
    return false;
  }
  // A max_tokens above this model's output ceiling (Claude Code asks for
  // 128000; Groq gpt-oss and Ollama Cloud's Nemotron stop at 65536) is the
  // request's shape, not the model's health and not missing tool support, so
  // it stays off the cooldown, penalty and tool-rejection books. A ceiling
  // learned just now makes the same route usable again at once (the next
  // attempt is clamped to it); one that was already applied and still got
  // rejected rules the model out for this request.
  const capBefore = learnedOutputCap(route.platform, route.modelId);
  const ceiling = learnOutputCapFromError(route, err);
  if (ceiling != null) {
    if (capBefore == null || ceiling < capBefore) {
      state.skipKeys.delete(`${route.platform}:${route.modelId}:${route.keyId}`);
    } else {
      state.skipModels.add(route.modelDbId);
    }
    return false;
  }
  if (consumeSkipBenchExemption(route, err)) return true;
  const decision = cooldownDecisionForError(route, err);
  setCooldown(route.platform, route.modelId, route.keyId, decision.durationMs, decision.source);
  // A truncated stream (200 + partial SSE, no [DONE]/finish_reason) on the
  // SAME route repeatedly is a sick edge, not bad luck β€” but a single one is
  // common enough on flaky free gateways that benching on first sight would
  // over-fire. Streak-bounded like empty completions (#751): N truncations in
  // a row on this platform+model+key bench the route for one cooldown window;
  // a success resets the streak (recordUpstreamSuccess).
  if (isStreamTruncatedError(err)) {
    const tk = `${route.platform}:${route.modelId}:${route.keyId}`;
    const streak = (truncationStreaks.get(tk) ?? 0) + 1;
    if (streak >= TRUNCATION_STREAK_LIMIT) {
      truncationStreaks.delete(tk);
      setCooldown(route.platform, route.modelId, route.keyId, TRUNCATION_BENCH_MS, 'heuristic');
      console.warn(`[FallbackLoop] ${route.platform} ${route.modelId} key ${route.keyId}: ${streak} consecutive truncated streams β€” benching the route for ${Math.round(TRUNCATION_BENCH_MS / 1000)}s`);
    } else {
      truncationStreaks.set(tk, streak);
    }
  } else {
    // Any other failure class on this route breaks the truncation streak: the
    // cooldown ladder is already handling whatever that is.
    truncationStreaks.delete(`${route.platform}:${route.modelId}:${route.keyId}`);
  }
  // A 400 "bad request" answer to a request that carries `tools` is how a
  // model that does not really support tool calling shows up (#1230). The 90s
  // transient cooldown above forgets it, so remember it per model+endpoint;
  // after a few distinct requests the router tries that model last for tool
  // requests (lib/tool-capability.ts). Only the final classification counts:
  // context-too-large, model-not-found and degraded 400s are other problems.
  if (state.wantsTools === true && classifyAttemptError(err) === 'provider_bad_request') {
    const tk = toolCapabilityKey(route.platform, route.modelId, route.endpointScope);
    const seen = (state.toolRejects ??= new Set<string>());
    if (!seen.has(tk)) {
      seen.add(tk);
      noteToolRejection(tk, now);
    }
  }
  // A suspended ACCOUNT fails every model behind the key identically. The
  // per-route cooldown above benches only the model that happened to be
  // tried, so the next request picks the platform's next model and pays the
  // same round trip again β€” on a platform with a large catalog that is the
  // whole failover budget, every request, until each model has tripped
  // separately. Bench the key across the platform's catalog in one go, and
  // rule the platform out for the rest of this request: a sibling key would
  // be a different account, but with the budget already spent on this one
  // the next PROVIDER is the better hop.
  if (isAccountSuspendedError(err)) {
    state.skipPlatforms.add(route.platform);
    benchKeyAcrossPlatform(route, decision.durationMs, decision.source, state, 'reports the account suspended');
  } else if (isPaymentRequiredError(err)) {
    // A 402 is the ACCOUNT's balance, not one model's (#1239: one broke
    // HuggingFace key answered three different models with 402 in a single
    // request, three hops spent rediscovering the same empty wallet). Bench
    // the key on every model of the platform for the credit window. Unlike a
    // suspension the platform itself stays in play: a sibling key is a
    // different wallet and may well have credits, so only THIS key is out.
    benchKeyAcrossPlatform(route, decision.durationMs, decision.source, state, 'is out of credits (402)');
  }
  // Model-level failure benching: a model failing across keys (or repeatedly on
  // one key) must sink out of routing instead of being re-picked every request.
  noteModelFailure(route, now);
  // Model-level penalty only when no sibling key can still serve (#454).
  if (!hasOtherUsableKey(route.modelDbId, route.keyId, state.skipKeys)) {
    // Hard limit signals (429/402) carry the heavier demotion; ordinary
    // upstream failures (5xx/timeout/empty stream) get a lighter one so the
    // model sinks gradually instead of being banished on a single blip.
    if (isRateLimitSignal(err)) {
      recordRateLimitHit(route.modelDbId);
    } else {
      recordModelFailure(route.modelDbId);
    }
  }
  learnLimitFromError(route.modelDbId, err);
  return false;
}

// ── Upstream 401 handling (key-fatal, not request-fatal) ─────────────────────
// A 401 means THIS key is bad, not this model or this request. The old behavior
// (non-retryable β†’ 502) stranded the provider's healthy sibling key and every
// other provider in the chain, and left the bad key in rotation failing traffic
// until the next 5-minute health cycle. Now the loop skips the key, benches the
// model+key long enough to cover the health cycle, and kicks an immediate
// targeted revalidation so a confirmed-bad key flips to status 'invalid' (and
// out of routing) within seconds instead of minutes.
export const AUTH_FAILURE_COOLDOWN_MS = 5 * 60 * 1000;

// Dedupe window for the fire-and-forget revalidation: many concurrent requests
// hitting the same bad key must not stampede the provider's validate endpoint.
const REVALIDATION_DEDUPE_MS = 30_000;
const lastRevalidation = new Map<number, number>();

function triggerKeyRevalidation(platform: string, keyId: number): void {
  const now = Date.now();
  const last = lastRevalidation.get(keyId) ?? 0;
  if (now - last < REVALIDATION_DEDUPE_MS) return;
  lastRevalidation.set(keyId, now);
  console.warn(`[FallbackLoop] Upstream 401 from ${platform} key ${keyId}; revalidating it now instead of waiting for the health cycle`);
  void checkKeyHealth(keyId).catch(err => {
    console.error(`[FallbackLoop] Immediate revalidation of key ${keyId} failed:`, err?.message);
  });
}

/**
 * Bookkeeping for an auth-fatal (401 / invalid key) attempt: skip the key for
 * this request, bench the model+key for the health-cycle window, and start an
 * immediate revalidation. Deliberately NO model penalty and NO limit-learning β€”
 * a bad key says nothing about the model's health.
 */
export function recordAuthFailure(route: RouteResult, state: FallbackState): void {
  state.skipKeys.add(`${route.platform}:${route.modelId}:${route.keyId}`);
  setCooldown(route.platform, route.modelId, route.keyId, AUTH_FAILURE_COOLDOWN_MS);
  triggerKeyRevalidation(route.platform, route.keyId);
}

/**
 * The success-side accounting every surface runs after a completed attempt:
 * count the request + its tokens against the model+key's rate-limit windows and
 * clear the model's 429 penalty. `rateLimitTokens` is whatever the surface metered
 * (the provider's usage.total_tokens for non-stream, an estimate for stream).
 */
export function recordUpstreamSuccess(route: RouteResult, rateLimitTokens: number, state?: FallbackState): void {
  // A tool-carrying request that was served proves two things (#1230): this
  // model does handle tools, and the request itself was well-formed, so the
  // models that answered it with a 400 earlier in this chain are the ones at
  // fault. Defer those for tool requests without waiting for more evidence.
  if (state?.wantsTools === true) {
    const winner = toolCapabilityKey(route.platform, route.modelId, route.endpointScope);
    clearToolRejections(winner);
    for (const tk of state.toolRejects ?? []) if (tk !== winner) benchForTools(tk);
  }
  recordRequest(route.platform, route.modelId, route.keyId);
  recordTokens(route.platform, route.modelId, route.keyId, rateLimitTokens);
  recordSuccess(route.modelDbId);
  // A served request proves the model+key can complete: the empty-completion
  // streak (#751) starts over.
  emptyCompletionStreaks.delete(`${route.platform}:${route.modelId}:${route.keyId}`);
  // A served request is the strongest evidence the route's edge is alive:
  // the truncated-stream streak (#1218) starts over too.
  truncationStreaks.delete(`${route.platform}:${route.modelId}:${route.keyId}`);
  // A served request is the strongest possible evidence the model works, so
  // clear any model-level failure streak that could bench it later.
  clearModelFailure(route);
  // A served request is the strongest possible evidence the key works, so clear
  // any stale 'error' status left by an earlier transport blip instead of waiting
  // for the next health pass to make the key routable again.
  markKeyHealthyFromRequest(route.keyId);
}

// ── Attempt trail ─────────────────────────────────────────────────────────────
// One record per dispatched-and-failed attempt, so the final exhaustion error
// can show the client WHAT was tried instead of only the last error. Key ids
// are internal DB integers; the trail shows a per-request ordinal (key1, key2…)
// instead, which is stable, readable, and leaks nothing.

export type AttemptErrorClass =
  | 'auth'
  | 'out_of_credits'
  | 'daily_quota_exhausted'
  | 'model_not_found'
  | 'forbidden'
  | 'context_too_large'
  | 'provider_bad_request'
  | 'empty_completion'
  | 'format_ignored'
  | 'invalid_tool_arguments'
  | 'timeout'
  | 'rate_limited'
  | 'upstream_error'
  | 'error';

export interface AttemptRecord {
  platform: string;
  modelId: string;
  keyOrdinal: number;
  errorClass: AttemptErrorClass;
}

export function classifyAttemptError(err: any): AttemptErrorClass {
  if (isKeyAuthError(err)) return 'auth';
  if (isPaymentRequiredError(err)) return 'out_of_credits';
  if (isDailyQuotaExhaustedError(err)) return 'daily_quota_exhausted';
  if (isModelNotFoundError(err)) return 'model_not_found';
  if (isModelAccessForbiddenError(err)) return 'forbidden';
  // Before the bad-request check: OpenAI-compat context errors arrive as
  // "API error 400: This model's maximum context length is …", which the
  // generic provider_bad_request rule would otherwise swallow.
  if (isContextTooLargeError(err)) return 'context_too_large';
  // A DEGRADED-function 400 (NVIDIA NIM, #522) is provider health, not request
  // shape β€” keep it out of provider_bad_request so the trail reads honestly.
  if (isProviderDegradedError(err)) return 'upstream_error';
  if (isProviderBadRequestError(err)) return 'provider_bad_request';
  const msg = (err?.message ?? '').toLowerCase();
  if (msg.includes('empty completion')) return 'empty_completion';
  if (msg.includes('ignored response_format') || msg.includes('truncated json')) return 'format_ignored';
  // Before the generic classes so the trail names the real cause rather than
  // booking a schema violation as a bare 'error'.
  if (msg.includes('invalid tool arguments')) return 'invalid_tool_arguments';
  if (isTimeoutErrorText(msg)) return 'timeout';
  if (msg.includes('429') || msg.includes('rate limit') || msg.includes('too many requests') || msg.includes('quota')) return 'rate_limited';
  const status = typeof err?.status === 'number' ? err.status : 0;
  if (status >= 500 || msg.includes('500') || msg.includes('502') || msg.includes('503') || msg.includes('unavailable') || msg.includes('internal server error')) return 'upstream_error';
  return 'error';
}

const TRAIL_MAX_SHOWN = 10;
const TRAIL_HEADER_MAX_LENGTH = 1024;

// ── Detailed failover trace header (opt-in) ──────────────────────────────────
// X-Fallback-Trail already tells a caller WHICH hops burned and WHY, but not
// how long they cost. That is the part an agent cannot reconstruct: a request
// answered in 40s reads identically whether one provider stalled for 39s or
// four failed fast. The per-hop timings and the redacted provider message are
// already collected for the request_attempts table; this exposes them on the
// response so the caller sees them without a dashboard round trip.
//
// Off by default. It widens what an already-loopback-ish surface reveals β€”
// hop timings plus provider error text β€” so it is opt-in like the discovery
// aliases, not something a default install starts emitting. Precedence matches
// the failover budget above: settings-table value, then env var, then off.
export const EXPOSE_FALLBACK_DETAIL_SETTING = 'expose_fallback_detail_header';

// Ten hops of "platform/model keyN=class t=…+…ms msg=…" with a capped message
// each. Kept well under the 8 KB total header budget that proxies commonly
// enforce, since this rides alongside X-Fallback-Trail's own 1 KB.
const DETAIL_HEADER_MAX_LENGTH = 2048;
// summarizeAttemptError caps at 200; the header wants a tighter budget so ten
// hops still fit. The full text remains in request_attempts either way.
const DETAIL_SUMMARY_MAX_LENGTH = 120;

export function isFallbackDetailHeaderEnabled(): boolean {
  let stored: string | undefined;
  try {
    stored = getSetting(EXPOSE_FALLBACK_DETAIL_SETTING);
  } catch {
    stored = undefined; // DB not ready β€” never throw on the proxy hot path
  }
  for (const raw of [stored, process.env.FALLBACK_DETAIL_HEADER]) {
    if (raw === undefined || raw.trim() === '') continue;
    const value = raw.trim().toLowerCase();
    return value === '1' || value === 'true';
  }
  return false;
}

/**
 * One `platform/model keyN=outcome t=<start>+<duration>ms msg=<summary>` segment
 * per hop, `; `-joined β€” the same leading shape as X-Fallback-Trail so the two
 * headers line up when read together.
 *
 * The summary is the already-redacted `errorSummary`, never `err.message`. Its
 * semicolons become commas because `; ` is the record separator; nothing else
 * is escaped, which keeps the value readable, and `safeHeaderValue` handles any
 * non-ASCII on the way out.
 */
export function formatAttemptDetail(records: AttemptTraceRecord[]): string {
  const shown = records.slice(0, TRAIL_MAX_SHOWN).map(r => {
    const parts = [
      `${r.platform}/${r.modelId}`,
      `key${r.keyOrdinal}=${r.outcome}`,
      `t=${r.startOffsetMs}+${r.durationMs}ms`,
    ];
    if (r.errorSummary) {
      parts.push(`msg=${r.errorSummary.slice(0, DETAIL_SUMMARY_MAX_LENGTH).replace(/;/g, ',')}`);
    }
    return parts.join(' ');
  });
  const extra = records.length - shown.length;
  return shown.join('; ') + (extra > 0 ? `; +${extra} more` : '');
}

export function formatAttemptTrail(attempts: AttemptRecord[]): string {
  const shown = attempts
    .slice(0, TRAIL_MAX_SHOWN)
    .map(a => `${a.platform}/${a.modelId} key${a.keyOrdinal}: ${a.errorClass}`);
  const extra = attempts.length - shown.length;
  return shown.join('; ') + (extra > 0 ? `; +${extra} more` : '');
}

/**
 * Set the failover diagnostics headers every surface stamps on its responses:
 * X-Fallback-Attempts (how many hops failed before this response) and
 * X-Fallback-Trail (what each hop was and why it failed). Until now the trail
 * only reached clients inside exhaustion error MESSAGES β€” a request that
 * eventually succeeded gave no hint that it burned five hops first, which is
 * exactly the case an operator wants to notice. Values go through
 * safeHeaderValue so a non-ASCII or control-laden model id can neither inject
 * header lines nor make Node reject the response outright (#619).
 *
 * When EXPOSE_FALLBACK_DETAIL_SETTING is on, X-Fallback-Detail joins them with
 * per-hop timings and the redacted provider message. Every caller runs inside
 * the request's AsyncLocalStorage scope, so the trace is readable here without
 * threading it through all five surfaces.
 *
 * Note what the detail header can and cannot contain: at flush time the trace
 * holds exactly the hops that already FAILED, each with final timings. The hop
 * currently being served is recorded only after dispatch returns β€” after
 * res.json(), or after the whole stream has finished β€” so its duration is not
 * knowable while headers are still open, on either path.
 */
export function setFallbackHeaders(
  res: { setHeader(name: string, value: string): void },
  failedAttempts: number,
  trail: AttemptRecord[] | undefined,
): void {
  if (failedAttempts > 0) res.setHeader('X-Fallback-Attempts', String(failedAttempts));
  if (trail && trail.length > 0) {
    const value = trail
      .slice(0, TRAIL_MAX_SHOWN)
      .map(a => `${a.platform}/${a.modelId} key${a.keyOrdinal}=${a.errorClass}`)
      .join('; ') + (trail.length > TRAIL_MAX_SHOWN ? `; +${trail.length - TRAIL_MAX_SHOWN} more` : '');
    // Ten hops of "platform/model keyN=class" outgrow the default cap, so the
    // trail gets its own budget.
    res.setHeader('X-Fallback-Trail', safeHeaderValue(value, TRAIL_HEADER_MAX_LENGTH));
  }

  // Checked before the setting so the overwhelmingly common no-failover request
  // never pays for a settings read.
  const records = getRequestTrace()?.records;
  if (records && records.length > 0 && isFallbackDetailHeaderEnabled()) {
    res.setHeader('X-Fallback-Detail', safeHeaderValue(formatAttemptDetail(records), DETAIL_HEADER_MAX_LENGTH));
  }
}

export interface ExhaustionBody {
  status: number;
  type: string;
  message: string;
  // Coarse class of the exhaustion, for surfaces that need to remap `type` to
  // their own wire vocabulary (the Anthropic route maps 'auth' β†’ 'api_error',
  // 'unavailable' β†’ 'overloaded_error', 'context_too_large' β†’
  // 'request_too_large', 'model_not_found' β†’ 'not_found_error', 'upstream' β†’
  // 'api_error').
  kind: 'auth' | 'bad_request' | 'rate_limit' | 'unavailable' | 'context_too_large' | 'model_not_found' | 'upstream';
  // Machine-readable code for the OpenAI-compatible error object.
  code?: string;
  // 429 exhaustions only: epoch ms of the earliest moment any benched candidate
  // becomes available again (soonest cooldown expiry). Rendered in the error
  // body and as a Retry-After header (seconds, ceil) by setExhaustionHeaders.
  retryAtMs?: number;
}

/**
 * The OpenAI-compatible `error` object for an exhaustion body β€” shared by every
 * OpenAI-shaped surface so the wire shape (message/type/code/retryAtMs) cannot
 * drift between them.
 */
export function exhaustionErrorPayload(body: ExhaustionBody): { message: string; type: string; code?: string; retryAtMs?: number } {
  const payload: { message: string; type: string; code?: string; retryAtMs?: number } = {
    message: body.message,
    type: body.type,
  };
  if (body.code) payload.code = body.code;
  if (body.retryAtMs != null) payload.retryAtMs = body.retryAtMs;
  return payload;
}

/**
 * Stamp the standard retry headers for an exhaustion body: a Retry-After of
 * ceil((retryAtMs - now) / 1000) seconds when the body carries a concrete
 * retry time. Callers must only invoke this before headers are flushed (i.e.
 * never on a committed SSE stream).
 */
export function setExhaustionHeaders(
  res: { setHeader(name: string, value: string): void },
  body: ExhaustionBody,
  now = Date.now(),
): void {
  if (body.retryAtMs == null) return;
  res.setHeader('Retry-After', String(Math.max(0, Math.ceil((body.retryAtMs - now) / 1000))));
}

export interface ExhaustionContext {
  attempts?: AttemptRecord[];
  // True when the wall-clock retry budget stopped the loop before maxRetries.
  timedOut?: boolean;
  budgetMs?: number;
  // Set (to the failure count) when the circuit-breaker guardrail stopped the
  // loop; renders as a 503 instead of a rate-limit exhaustion.
  breakerFails?: number;
}

// Attempt classes that mean "this candidate is unavailable until a KNOWN time"
// β€” a rate-limit window, a benched daily allocation, an out-of-credits key
// (day bench), or a tier-forbidden model (day bench). When EVERY attempt is in
// this family the pool recovers by itself, so the honest exhaustion is a 429
// with a concrete retry time. Anything else in the mix (a 5xx, a timeout, a
// vanished model) means waiting is not a promise, so the mixed case renders a
// 502 instead.
const UNAVAILABLE_UNTIL_KNOWN_TIME: ReadonlySet<AttemptErrorClass> = new Set([
  'rate_limited',
  'daily_quota_exhausted',
  'out_of_credits',
  'forbidden',
]);

/**
 * The shared exhaustion response body β€” the single failure-kind β†’ terminal-
 * status ladder every surface renders. Aggregated over the per-attempt failure
 * classes (most-specific first):
 *   - Every attempt failed auth (401/invalid key) β†’ 502 provider_error saying the
 *     PROVIDER keys are bad β€” distinct from a rate-limit exhaustion, and never
 *     'authentication_error' (which would wrongly blame the CLIENT's key).
 *   - Every attempt died on a context/prompt-too-large rejection β†’ 413: no
 *     candidate can fit this request; retrying cannot help, shrinking it can.
 *   - Every attempt got a model-not-found/gone from its provider β†’ 404: the
 *     model has been removed upstream everywhere we route it. (No 410 β€” the
 *     catalog keeps no removal tombstones, so "verifiably existed before" is
 *     not determinable here.)
 *   - A chain that died on a DEGRADED-function 400 (NVIDIA NIM, #522) β†’ 503:
 *     provider capacity, not a bad request.
 *   - A request every routed provider rejected as invalid β†’ 400
 *     invalid_request_error, not a misleading rate-limit exhaustion.
 *   - Circuit-breaker stop β†’ 503 (the pool looks unhealthy; retry later).
 *   - Every attempt unavailable-until-known-time (rate limits, benched
 *     quotas/credits/tiers) β†’ 429 rate_limit_error with `retryAtMs` (soonest
 *     cooldown expiry) for a matching Retry-After header.
 *   - Mixed/other upstream failures (5xx, timeouts, transport errors) β†’ 502
 *     provider_error: the UPSTREAMS failed. Never 500 β€” that status is
 *     reserved for our own bugs.
 * All bodies carry the attempt trail (what was tried, per attempt) and, where
 * meaningful, the soonest-cooldown-reset hint.
 */
export function exhaustedRetryError(lastError: any, maxRetries?: number, ctx?: ExhaustionContext): ExhaustionBody {
  const safeLastError = sanitizeProviderErrorMessage(lastError?.message);
  const attempts = ctx?.attempts ?? [];
  const trail = attempts.length > 0 ? ` Attempt trail: ${formatAttemptTrail(attempts)}.` : '';
  const budgetNote = ctx?.timedOut
    ? ` (stopped early: retry time budget ${Math.round((ctx.budgetMs ?? 0) / 1000)}s exceeded β€” one failover hop is always allowed, and past that the budget stops starting further retries and cancels an attempt still waiting on its first byte; raise FALLBACK_TIME_BUDGET_MS or the fallback_time_budget_ms setting to allow a longer failover chain)`
    : '';
  const everyAttempt = (cls: AttemptErrorClass | ReadonlySet<AttemptErrorClass>): boolean =>
    attempts.length > 0 && attempts.every(a => (cls instanceof Set ? cls.has(a.errorClass) : a.errorClass === cls));

  if (everyAttempt('auth')) {
    return {
      kind: 'auth',
      status: 502,
      type: 'provider_error',
      code: 'provider_authentication_failed',
      message: `All ${attempts.length} attempted provider key(s) failed authentication${budgetNote}. ` +
        'The configured upstream API key(s) look invalid or expired; they are being revalidated now and will be marked invalid automatically. ' +
        `Check the provider keys in the dashboard.${trail} Last error: ${safeLastError}`,
    };
  }

  // Aggregate diagnoses before the lastError-shape ones below: when EVERY
  // attempt failed the same way, that unanimity is stronger evidence than the
  // shape of whichever error happened to come last.
  if (everyAttempt('context_too_large')) {
    return {
      kind: 'context_too_large',
      status: 413,
      type: 'invalid_request_error',
      code: 'context_length_exceeded',
      message: `The request is too large for every routed candidate: all ${attempts.length} attempt(s) were rejected as ` +
        `over the model's context/size limit${budgetNote}. Retrying will not help β€” reduce the prompt/history size or ` +
        `enable a larger-context model.${trail} Last error: ${safeLastError}`,
    };
  }

  if (everyAttempt('model_not_found')) {
    return {
      kind: 'model_not_found',
      status: 404,
      type: 'invalid_request_error',
      code: 'model_not_found',
      message: `Every routed provider reports the model as not found or removed upstream (${attempts.length} attempt(s))` +
        `${budgetNote}. The catalog entry looks stale; pick another model or call /v1/models for the available list.` +
        `${trail} Last error: ${safeLastError}`,
    };
  }

  // A chain that died on a DEGRADED-function 400 (NVIDIA NIM, #522) is a
  // provider-capacity outage, not a bad request: render 503 so clients retry
  // later instead of "fixing" a request that was never broken.
  if (isProviderDegradedError(lastError)) {
    return {
      kind: 'unavailable',
      status: 503,
      type: 'service_unavailable',
      code: 'provider_degraded',
      message: `The routed provider reported the model's hosted deployment as temporarily degraded${budgetNote}. ` +
        `This is a provider-side condition; retry later or route to another provider.${trail} Last error: ${safeLastError}`,
    };
  }

  // 400 only when the trail agrees with the last error: EVERY attempt was a
  // provider-side request rejection (or the legacy no-trail shape). A mixed
  // trail β€” stale models, an empty wallet, a rate limit, and one bad-request
  // hop that happened to come last β€” is not evidence the caller's request is
  // invalid, and rendering it as one sent users debugging their own payload
  // (#1239: 5Γ— provider_bad_request + 3Γ— out_of_credits read as "All routed
  // providers rejected the request as invalid"). Those fall through to the
  // mixed 502 below, whose message names the class breakdown.
  if (isProviderBadRequestError(lastError) && (attempts.length === 0 || everyAttempt('provider_bad_request'))) {
    return {
      kind: 'bad_request',
      status: 400,
      type: 'invalid_request_error',
      code: 'provider_rejected_request',
      message: `All routed providers rejected the request as invalid${budgetNote}.${trail} Last error: ${safeLastError}`,
    };
  }

  // Circuit-breaker guardrail stop. Checked after the aggregate and bad-request
  // diagnoses, which are more specific about WHY the pool is failing.
  if (ctx?.breakerFails) {
    const breakerEta = formatResetEta(getSoonestCooldownExpiry());
    const breakerEtaNote = breakerEta ? ` Soonest cooldown reset ${breakerEta}.` : '';
    return {
      kind: 'unavailable',
      status: 503,
      type: 'service_unavailable',
      code: 'upstream_unhealthy',
      message: `Failover stopped early by the circuit-breaker guardrail: ${ctx.breakerFails} consecutive upstream ` +
        `failure${ctx.breakerFails === 1 ? '' : 's'} (max_consecutive_upstream_fails). The enabled pool looks ` +
        `unhealthy right now, so the remaining candidates were skipped instead of burning quota on them.` +
        `${breakerEtaNote}${trail} Last error: ${safeLastError}`,
    };
  }

  // 429 only when EVERY candidate is unavailable until a known time (or the
  // caller gave us no per-attempt classes to aggregate β€” the legacy shape).
  if (attempts.length === 0 || everyAttempt(UNAVAILABLE_UNTIL_KNOWN_TIME)) {
    const attemptCount = attempts.length > 0 ? attempts.length : maxRetries;
    const scope = attemptCount == null
      ? 'All models rate-limited'
      : `All models rate-limited after ${attemptCount} attempt${attemptCount === 1 ? '' : 's'}`;
    const retryAtMs = getSoonestCooldownExpiry() ?? undefined;
    const eta = formatResetEta(retryAtMs);
    const etaNote = eta ? ` Soonest cooldown reset ${eta}.` : '';
    return {
      kind: 'rate_limit',
      status: 429,
      type: 'rate_limit_error',
      code: 'rate_limit_exceeded',
      ...(retryAtMs != null ? { retryAtMs } : {}),
      message: `${scope}${budgetNote}.${etaNote}${trail} Last error: ${safeLastError}`,
    };
  }

  // Mixed/other upstream failures (5xx, timeouts, transport errors, or a mix of
  // those with rate limits): the upstream pool failed us, so say 502 β€” not 429
  // (which would promise recovery-by-waiting the attempts don't support) and
  // not 500 (which would blame our own code).
  //
  // The breakdown names how many attempts fell in each class so a mixed trail
  // reads as what it is. When SOME hops were provider-side request rejections
  // the body must not promise the request was fine either β€” it says which
  // providers rejected it and leaves the verdict to the reader.
  const breakdown = formatClassBreakdown(attempts);
  const someBadRequest = attempts.some(a => a.errorClass === 'provider_bad_request');
  const verdict = someBadRequest
    ? 'Most of these failures are provider-side (stale models, exhausted credits or quotas), but some providers ' +
      'also rejected the request shape β€” check the attempt trail for which ones before changing your request.'
    : 'This is a provider-side failure, not a problem with your request; retry, or check provider status.';
  return {
    kind: 'upstream',
    status: 502,
    type: 'provider_error',
    code: 'upstream_failed',
    message: `All ${attempts.length} routed attempt(s) failed with upstream provider errors${breakdown}${budgetNote}. ` +
      `${verdict}${trail} Last error: ${safeLastError}`,
  };
}

/** " (provider_bad_request Γ—5, out_of_credits Γ—3)" β€” the per-class tally of a
 *  mixed trail, most frequent first; empty when there is nothing to tally. */
function formatClassBreakdown(attempts: readonly AttemptRecord[]): string {
  if (attempts.length === 0) return '';
  const counts = new Map<AttemptErrorClass, number>();
  for (const a of attempts) counts.set(a.errorClass, (counts.get(a.errorClass) ?? 0) + 1);
  const parts = [...counts.entries()]
    .sort((a, b) => b[1] - a[1])
    .map(([cls, n]) => `${cls} Γ—${n}`);
  return ` (${parts.join(', ')})`;
}

// ── Routing exhaustion (zero attempts ran) ───────────────────────────────────
// When routeRequest gives up before ANY upstream was tried, the only evidence
// is its per-candidate diagnostics. Map them onto the same honest-terminal-
// status taxonomy the attempt ladder uses:
//   - nothing configured at all (empty chain, or every candidate lacks a
//     provider/usable key) β†’ 503: no amount of waiting or request-shrinking
//     helps; the operator must add keys.
//   - every candidate rejected the request as too big for its context/TPM
//     window β†’ 413.
//   - at least one candidate is merely rate-limited/on cooldown (and the rest
//     are at worst unconfigured/too-small) β†’ 429 with the soonest cooldown
//     expiry as retryAtMs.
//   - anything else (capability filters like vision/tools, mixed reasons) β†’
//     the router's status (429) with a generic routing_exhausted code.
type RoutingDiagClass = 'config' | 'too_large' | 'time_bound' | 'monthly_budget' | 'other';

function classifyRoutingDiagLine(line: string): RoutingDiagClass {
  const l = line.toLowerCase();
  // "< estimated" first: the tpm_limit-too-small line also contains 'tpm',
  // which would otherwise misread as a transient window.
  if (l.includes('< estimated')) return 'too_large';
  const monthlyReasons = l.match(/key\(s\) β€” (.*)$/)?.[1];
  if (monthlyReasons?.split(', ').every(reason => /^monthly-budget-cap:\d+$/.test(reason))) return 'monthly_budget';
  if (/no provider registered|no enabled\+healthy key|no usable key|decrypt-error|no-resolved-provider|custom-key-mismatch/.test(l)) return 'config';
  if (/cooldown|rpm|rpd|tpm|tpd|provider-daily-cap|provider-minute-cap|provider-daily-token-cap|key-concurrency|monthly-budget-cap/.test(l)) return 'time_bound';
  return 'other';
}

export function routingExhaustionBody(routeErr: any): ExhaustionBody {
  const diag: string[] = Array.isArray(routeErr?.diagnostics) ? routeErr.diagnostics : [];
  const message: string = routeErr?.message ?? 'No model available to route this request';
  const classes = diag.map(classifyRoutingDiagLine);

  if (diag.length === 0 || classes.every(c => c === 'config')) {
    return {
      kind: 'unavailable',
      status: 503,
      type: 'service_unavailable',
      code: 'no_providers_configured',
      message: diag.length === 0
        ? `No models are enabled/configured to serve this request. Add provider API keys and enable models in the dashboard. ${message}`
        : `No candidate model has a configured, usable provider key. Add provider API keys in the dashboard. ${message}`,
    };
  }

  if (classes.every(c => c === 'too_large' || c === 'config') && classes.includes('too_large')) {
    return {
      kind: 'context_too_large',
      status: 413,
      type: 'invalid_request_error',
      code: 'context_length_exceeded',
      message: `The request is too large for every available candidate's context/token window. ` +
        `Reduce the prompt/history size or enable a larger-context model. ${message}`,
    };
  }

  if (classes.includes('monthly_budget') && classes.every(c => c === 'monthly_budget' || c === 'config' || c === 'too_large')) {
    return {
      kind: 'rate_limit', status: 429, type: 'rate_limit_error', code: 'quota_exceeded',
      retryAtMs: Date.parse(nextMonthResetAt()),
      message: `Monthly key budget exhausted. Raise the cap or wait until the next UTC month. ${message}`,
    };
  }

  if (classes.some(c => c === 'time_bound') && classes.every(c => c !== 'other')) {
    const retryAtMs = getSoonestCooldownExpiry() ?? undefined;
    return {
      kind: 'rate_limit',
      status: 429,
      type: 'rate_limit_error',
      code: 'rate_limit_exceeded',
      ...(retryAtMs != null ? { retryAtMs } : {}),
      message,
    };
  }

  // Capability filters or mixed reasons: keep the router's verdict (429) but
  // stamp a machine-readable code so clients can tell it from a plain
  // rate-limit exhaustion.
  return {
    kind: 'rate_limit',
    status: typeof routeErr?.status === 'number' ? routeErr.status : 429,
    type: 'rate_limit_error',
    code: 'routing_exhausted',
    message,
  };
}

/** Log the router's per-candidate disposition for a zero-attempt exhaustion.
 *  Lives in the loop, not the surfaces: routing gave up before any upstream was
 *  tried, so nothing else records WHY the pool was empty, and an opaque
 *  routing_error is indistinguishable from a genuinely dry pool (issue _1).
 *  Every surface used to be responsible for logging this itself and only
 *  routes/proxy.ts ever did, so /v1/messages, /v1/responses and the inbound
 *  wires logged nothing. The loop owns the format; surfaces only name
 *  themselves via FallbackHooks.logIdentity. */
function logRoutingExhaustion(routeErr: any, identity?: FallbackHooks['logIdentity']): void {
  const disposition: string[] = Array.isArray(routeErr?.diagnostics) ? routeErr.diagnostics : [];
  const surface = identity?.surface ?? 'unidentified surface';
  const req = identity?.requestId ? ` req=${identity.requestId.replace(/-/g, '').slice(0, 6)}` : '';
  const requested = identity?.requestedModel ? ` requested=${identity.requestedModel}` : '';
  console.warn(
    `[FallbackLoop] ${surface} routing exhausted (no upstream tried)${req}${requested} ` +
    `candidates=${disposition.length}` +
    (disposition.length ? `:\n  ${disposition.join('\n  ')}` : ''),
  );
}

// What a surface's dispatch() returns to signal the response is finished and the
// loop must stop:
//   'done'      β€” the attempt succeeded and the full response was sent.
//   'committed' β€” a stream already flushed real bytes to the client, then hit a
//                 mid-stream error the surface surfaced honestly; no failover is
//                 possible, so stop without recording another retry.
export type DispatchOutcome = 'done' | 'committed';

/** Per-attempt handles the loop hands to dispatch. */
export interface DispatchContext {
  /**
   * Cancel this attempt's time-budget hedge. Idempotent and always safe to
   * call, including when hedging is not armed at all. See FallbackHooks.dispatch.
   */
  disarmHedge(): void;
}

// Per-request exhaustion metadata handed to the exhaustion hooks, so each
// surface can stamp X-Fallback-Attempts on error responses (previously
// success-only) without re-deriving the count.
export interface ExhaustionInfo {
  attempts: AttemptRecord[];
  timedOut: boolean;
}

export interface FallbackHooks {
  // Defaults to FALLBACK_MAX_RETRIES.
  maxRetries?: number;
  // Base wall-clock retry budget override, mostly for tests. Defaults to
  // getFallbackTimeBudgetMs() (setting β†’ env β†’ 45s; 0 disables).
  // Successful endpoint TTFB history can widen this floor.
  timeBudgetMs?: number;
  // Circuit-breaker threshold override, mostly for tests. Defaults to
  // getMaxConsecutiveUpstreamFails() (setting β†’ env β†’ 0 = disabled).
  breakerLimit?: number;
  // When provided, the loop records every failed attempt into THIS array (it
  // is the same array used for exhaustion bodies), so the surface can stamp
  // X-Fallback-Trail on successful responses too.
  attemptLog?: AttemptRecord[];
  // Names this surface in the shared routing-exhaustion diagnostics line the
  // loop logs when route() gave up before any upstream was tried (see
  // logRoutingExhaustion). Absent = the line still fires, without identifying
  // the surface or the request.
  logIdentity?: { surface: string; requestId?: string; requestedModel?: string };
  // Returns true once the client has hung up. Checked before STARTING each
  // retry: a chain nobody is waiting for must not keep burning provider
  // quota. Surfaces additionally thread a client-disconnect AbortSignal into
  // the provider options (CompletionOptions.signal), so the in-flight
  // attempt's fetch/body/stream is canceled the moment the client goes; the
  // resulting client-abort throw stops the loop without any failure
  // bookkeeping (see the isClientAbortError branch below).
  clientGone?: () => boolean;
  // Fallback-v2 hedging: when provided, the loop starts a per-attempt timer
  // (remaining wall-clock budget) and calls this to ABORT the in-flight
  // upstream instead of just refusing to start the next retry behind a
  // stalled attempt. The surface aborts its composed fetch signal with
  // newHedgeAbortError(); the loop renders timedOut exhaustion, and benches the
  // model+key only when that attempt ate the whole budget by itself (see the
  // isHedgeAbortError branch below). Absent = pre-v2 behavior.
  abortInFlight?: () => void;
  // Skip state; recordRetryableFailure / recordAuthFailure (called by the loop)
  // mutate it, and the surface's route() reads it to exclude failed keys/models.
  state: FallbackState;

  /**
   * Pick a route for this attempt. Reads state.skipKeys / state.skipModels /
   * state.skipPlatforms.
   * Throws the router's RouteError when the pool is exhausted before any
   * upstream is tried (caught by the loop β†’ onRoutingExhausted).
   */
  route(attempt: number): RouteResult;

  /**
   * Run one attempt against the chosen route. Return 'done' on success or
   * 'committed' when a stream already sent bytes and handled its own mid-stream
   * error. THROW a (possibly synthetic) provider error β€” an upstream HTTP error,
   * or an "empty completion" / "unparseable inline tool-call dialect" Error the
   * classifier already treats as retryable β€” to trigger failover; a
   * non-retryable throw becomes onFatal. A pre-commit failure MUST throw (not
   * return 'committed') so the loop can fail over invisibly. The loop enforces
   * this contract: any other return value is a programming error and fails
   * loudly instead of silently swallowing the request.
   *
   * `ctx.disarmHedge()` cancels the time-budget hedge for THIS attempt. Call it
   * the moment the attempt proves it is alive (first byte / headers flushed):
   * past that point the budget must not cancel it, because the answer is
   * already on its way and killing it would truncate a healthy response for no
   * failover benefit. Streaming surfaces are expected to call it; a
   * non-streaming attempt has nothing to disarm until it returns.
   */
  dispatch(route: RouteResult, attempt: number, ctx: DispatchContext): Promise<DispatchOutcome>;

  /** Trace + log a per-attempt failure (per-surface scope + logRequest args). */
  logFailure(route: RouteResult, err: any, attempt: number): void;

  /** Render a non-retryable provider error (per-surface body/status). `attempt`
   *  is the failing attempt's index = the number of prior fallback hops. */
  onFatal(route: RouteResult, err: any, attempt: number): void;

  /**
   * Render exhaustion when route() threw. `exhaustion` is always the shared
   * honest-status body: built from the attempt trail when at least one attempt
   * ran, or from routeErr's routing diagnostics (routingExhaustionBody) when
   * routing gave up before any upstream was tried. `lastError` is null in the
   * zero-attempt case; `routeErr` is passed for logging/diagnostics.
   */
  onRoutingExhausted(lastError: any, routeErr: any, exhaustion: ExhaustionBody, info: ExhaustionInfo): void;

  /** Render exhaustion after the attempt cap or the time budget was hit. */
  onExhausted(exhaustion: ExhaustionBody, info: ExhaustionInfo): void;
}

/**
 * The shared attempt loop. Owns iteration, the wall-clock retry budget, the
 * routeRequest-exhaustion path, the auth/retryable/fatal classification, the
 * per-failure bookkeeping (recordRetryableFailure / recordAuthFailure), the
 * attempt trail, and the final exhaustion body. Everything surface-specific β€”
 * request translation, stream framing, error-body shape, context handoff, group
 * routing β€” lives in the hooks.
 */
export async function runFallbackLoop(hooks: FallbackHooks): Promise<void> {
  // Durable per-attempt trace (P2 #15): every dispatched attempt β€” including
  // the successful final one β€” is recorded with timing and outcome, then
  // persisted as one insert batch into `request_attempts`, keyed to the
  // terminal `requests` row of this ladder. The trace rides AsyncLocalStorage
  // so logRequest() (called deep inside the surfaces' dispatch closures) can
  // report back the row ids it writes without any surface changing. The flush
  // runs after the loop returns β€” i.e. after the response is finished β€” so it
  // never sits on the client's latency path, and it is a no-op when no
  // `requests` row was written (a pure abort writes its own 'canceled' row,
  // so its trace persists too).
  const trace = newRequestTrace();
  try {
    await runWithRequestTrace(trace, () => runFallbackLoopAttempts(hooks, trace));
  } finally {
    persistRequestAttempts(trace);
  }
}

async function runFallbackLoopAttempts(hooks: FallbackHooks, trace: RequestTrace): Promise<void> {
  const maxRetries = hooks.maxRetries ?? FALLBACK_MAX_RETRIES;
  const baseBudgetMs = hooks.timeBudgetMs ?? getFallbackTimeBudgetMs();
  const startedAt = Date.now();
  const attempts: AttemptRecord[] = hooks.attemptLog ?? [];
  const keyOrdinals = new Map<string, number>();
  const keyOrdinal = (route: RouteResult): number => {
    const key = `${route.platform}:${route.keyId}`;
    let ord = keyOrdinals.get(key);
    if (ord === undefined) {
      ord = keyOrdinals.size + 1;
      keyOrdinals.set(key, ord);
    }
    return ord;
  };
  let lastError: any = null;

  // Circuit-breaker guardrail (default off): the Nth consecutive upstream
  // failure aborts the chain with a 503 instead of grinding the remaining
  // candidates of a pool that is failing across the board. Auth failures count
  // too β€” a wall of dead keys is exactly the "stop early" case. Within one
  // request every recorded failure is consecutive by construction (a success
  // ends the loop), so this is "max upstream failures per request".
  const breaker = newBreaker(hooks.breakerLimit);
  const stopIfBreakerTripped = (): boolean => {
    if (!recordBreakerFailure(breaker)) return false;
    // Tripped after the client already hung up: stop the chain, but there is
    // no socket to render an exhaustion body to (mirrors the loop-top
    // clientGone check).
    if (hooks.clientGone?.()) {
      console.log(`[FallbackLoop] breaker tripped after client disconnect β€” stopping without rendering (${attempts.length} failed attempt(s))`);
      return true;
    }
    hooks.onExhausted(
      exhaustedRetryError(lastError, maxRetries, { attempts, breakerFails: breaker.consecutive }),
      { attempts, timedOut: false },
    );
    return true;
  };

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    // Client disconnect: nobody is waiting for this chain anymore, so stop
    // burning provider quota on retries. Nothing to render β€” the socket is
    // gone β€” so return without calling any exhaustion hook.
    if (attempt > 0 && hooks.clientGone?.()) {
      console.log(`[FallbackLoop] client disconnected β€” stopping failover after ${attempts.length} failed attempt(s)`);
      return;
    }

    let route: RouteResult;
    try {
      route = hooks.route(attempt);
    } catch (routeErr) {
      // With no candidate to supply an endpoint-specific allowance, retain
      // the original timeout diagnosis when the base budget is already spent.
      if (attempt > 1 && baseBudgetMs > 0 && Date.now() - startedAt >= baseBudgetMs) {
        hooks.onExhausted(
          exhaustedRetryError(lastError, maxRetries, { attempts, timedOut: true, budgetMs: baseBudgetMs }),
          { attempts, timedOut: true },
        );
        return;
      }
      const exhaustion = lastError
        ? exhaustedRetryError(lastError, undefined, { attempts })
        : routingExhaustionBody(routeErr);
      // Zero attempts ran: log the router's per-candidate disposition, the only
      // record of why the pool was empty. With prior attempts the trail in the
      // exhaustion body already explains the failure.
      if (!lastError) logRoutingExhaustion(routeErr, hooks.logIdentity);
      hooks.onRoutingExhausted(lastError, routeErr, exhaustion, { attempts, timedOut: false });
      return;
    }

    let hedgeTimer: NodeJS.Timeout | undefined;
    try {
    // Select the endpoint before checking the budget: a slow endpoint may
    // still have time even after the base budget has expired. Recompute from
    // the base for every candidate so its allowance cannot leak to a faster
    // endpoint later in the ladder. The clock still starts at loop entry.
    const budgetMs = attempt > 1
      ? getEndpointTimeBudgetMs(baseBudgetMs, route.platform, route.endpointScope)
      : baseBudgetMs;
    // Attempt 0 and the first retry remain exempt (#751). Routing reserves a
    // lease, so even a candidate rejected here must pass through finally.
    if (attempt > 1 && budgetMs > 0 && Date.now() - startedAt >= budgetMs) {
      hooks.onExhausted(
        exhaustedRetryError(lastError, maxRetries, { attempts, timedOut: true, budgetMs }),
        { attempts, timedOut: true },
      );
      return;
    }

    // Per-attempt trace record: pushed exactly once per dispatched attempt, on
    // whichever exit the attempt takes. startOffsetMs/durationMs bracket the
    // dispatch (for a successful stream, durationMs runs until the response
    // finished β€” that IS the attempt). When the attempt ended on an error, a
    // short REDACTED summary of it rides along β€” the outcome class alone loses
    // the provider's actual words, which is exactly what the dashboard's
    // drill-down needs to answer "why did this hop fail".
    const attemptStartedAt = Date.now();
    const traceAttempt = (outcome: AttemptOutcome, err?: any): void => {
      trace.records.push({
        ordinal: trace.records.length,
        platform: route.platform,
        modelId: route.modelId,
        keyOrdinal: keyOrdinal(route),
        keyLabel: route.keyLabel ?? null,
        outcome,
        startOffsetMs: attemptStartedAt - startedAt,
        durationMs: Date.now() - attemptStartedAt,
        errorSummary: err != null ? summarizeAttemptError(err?.message) : null,
      });
    };

    // Everything from here to the end of the iteration runs inside a finally that
    // frees the route's in-flight lease. Every exit β€” success, auth rotation,
    // retryable continue, fatal, breaker trip, contract violation β€” passes through
    // it, so no path can leak a lease and leave the key's concurrency budget short.
    // Success accounting happens inside dispatch, so the persisted counters are
    // already written by the time the provisional lease goes away.
    // Fallback-v2 hedging: arm a timer for the remaining wall-clock budget so a
    // STALLED attempt is aborted mid-flight (abortInFlight) instead of only
    // refusing to start the next retry behind it. Mirrors the loop-top budget
    // check β€” attempt 0 and the first retry always run (#751).
    //
    // The timer only covers the silent window. dispatch calls ctx.disarmHedge()
    // as soon as the attempt proves it is alive (first byte / headers flushed),
    // because past that point cancelling would truncate a healthy response and
    // buy nothing: a committed stream can no longer fail over anyway. Slow is
    // not the same as stalled, and only stalled is worth killing.
    const disarmHedge = () => {
      if (hedgeTimer) {
        clearTimeout(hedgeTimer);
        hedgeTimer = undefined;
      }
    };
    if (attempt > 1 && budgetMs > 0 && hooks.abortInFlight) {
      const remaining = budgetMs - (Date.now() - startedAt);
      if (remaining > 0) {
        hedgeTimer = setTimeout(() => {
          console.log(`[FallbackLoop] retry time budget (${budgetMs}ms) expired with no first byte on ${route.platform}/${route.modelId} β€” aborting stalled upstream`);
          hooks.abortInFlight?.();
        }, remaining);
      }
    }
    let outcome: DispatchOutcome;
    try {
      // #590 (per-key proxy): if THIS key carries its own proxy URL, route the
      // attempt through it (withKeyProxy β†’ proxyFetch reads the ALS store);
      // otherwise the global proxy / direct path applies as before. The URL
      // arrives already decrypted on the route (services/router.ts), so an
      // attempt costs nothing extra β€” no query, no decrypt.
      outcome = await withKeyProxy(route.proxyUrl, () => hooks.dispatch(route, attempt, { disarmHedge }));
    } catch (err: any) {
      // Client-caused abort: the composed fetch signal fired because OUR
      // client hung up mid-attempt (see newClientAbortError). Not a
      // provider-health signal β€” no cooldown, no penalty, no failure stats,
      // no logFailure 'error' row β€” and no further attempts either: nobody is
      // waiting, and there is no socket to render anything to. The finally
      // below still frees the in-flight lease.
      if (isClientAbortError(err)) {
        const elapsedMs = Date.now() - startedAt;
        console.log(`[FallbackLoop] client disconnected mid-attempt on ${route.platform}/${route.modelId} β€” upstream canceled, stopping without benching`);
        // Visibility row (#752): a pure abort used to leave NOTHING in the
        // requests table, so the dashboard showed no trace of the request.
        // Status 'canceled' is neither success nor failure β€” every stats/
        // scoring query excludes it β€” and this row is also the parent the
        // attempt-trace batch below keys to.
        logRequest(route.platform, route.modelId, route.keyId, 'canceled', 0, 0, elapsedMs,
          `client disconnected after ${(elapsedMs / 1000).toFixed(1)}s; upstream request canceled`);
        traceAttempt('client_abort');
        return;
      }
      // Time-budget hedge abort: the wall-clock retry budget expired while this
      // attempt was still in flight, and the surface aborted the composed fetch
      // signal (see newHedgeAbortError). The budget is spent either way, so we
      // render timedOut exhaustion exactly like the loop-top budget check does.
      //
      // Whether it also counts as a provider-health signal depends on how much
      // of the silence belongs to THIS attempt. Sharing a budget that earlier
      // attempts already mostly spent says nothing about this route β€” it was
      // simply last in line, and cancelling it is a scheduling accident. But an
      // attempt that stayed silent for most of the operator's whole declared
      // patience on its own produced no first byte over that entire window,
      // and that is a stalled upstream. Left unbenched it stays at the head of the route
      // order and re-stalls every subsequent request, burning the full budget
      // each time and starving the healthy routes queued behind it β€” observed
      // in production as one dead free-tier route 45s-ing every request for
      // hours. Benching costs a short transient cooldown (a timeout is not a
      // rate-limit signal, so cooldownDecisionForError keeps it light) plus the
      // gradual model-level sink, both of which decay on their own if the route
      // recovers.
      if (isHedgeAbortError(err)) {
        const elapsedMs = Date.now() - startedAt;
        const attemptElapsedMs = Date.now() - attemptStartedAt;
        const ownedWholeBudget = budgetMs > 0
          && attemptElapsedMs >= budgetMs * HEDGE_BENCH_MIN_SILENT_FRACTION;
        if (ownedWholeBudget) {
          hooks.logFailure(route, err, attempt);
          recordRetryableFailure(route, err, hooks.state);
        }
        console.log(`[FallbackLoop] retry time budget expired mid-attempt on ${route.platform}/${route.modelId} after ${(elapsedMs / 1000).toFixed(1)}s β€” rendering timedOut exhaustion ${ownedWholeBudget ? `and benching the route (silent for the whole ${budgetMs}ms budget)` : 'without benching'}`);
        hooks.onExhausted(
          exhaustedRetryError(lastError, maxRetries, { attempts, timedOut: true, budgetMs }),
          { attempts, timedOut: true },
        );
        traceAttempt('timeout', err);
        return;
      }
      hooks.logFailure(route, err, attempt);
      if (isKeyAuthError(err)) {
        // KEY-fatal, not request-fatal: rotate past the bad key and revalidate
        // it immediately instead of 502-ing while healthy routes sit idle.
        recordAuthFailure(route, hooks.state);
        attempts.push({ platform: route.platform, modelId: route.modelId, keyOrdinal: keyOrdinal(route), errorClass: 'auth' });
        traceAttempt('auth', err);
        lastError = err;
        if (stopIfBreakerTripped()) return;
        continue;
      }
      if (isRetryableError(err)) {
        const exempt = recordRetryableFailure(route, err, hooks.state);
        // An in-band provider error that arrives only after the attempt has
        // silently consumed most of the operator's whole budget (#1218 Gap 3:
        // nvidia ran 140.7s before surfacing "Service temporarily overloaded",
        // leaving scraps for the next hop) behaved like a stall for its entire
        // window β€” the hedge-abort bench would have fired had the abort landed
        // first. Give the late error the same treatment the hedge abort gets:
        // bench the route so the ladder's next hop keeps a usable budget,
        // instead of re-stalling on this route every request. Errors that
        // arrive EARLY (the common Groq tool_use_failed shape) stay unbenced β€”
        // a fast verdict costs the ladder nothing.
        const errStr = err?.message ?? '';
        if (
          budgetMs > 0
          && typeof errStr === 'string' && errStr.includes('in-band provider error')
          && Date.now() - attemptStartedAt >= budgetMs * HEDGE_BENCH_MIN_SILENT_FRACTION
        ) {
          setCooldown(route.platform, route.modelId, route.keyId, TRUNCATION_BENCH_MS, 'heuristic');
          console.warn(`[FallbackLoop] ${route.platform}/${route.modelId}: in-band provider error after ${((Date.now() - attemptStartedAt) / 1000).toFixed(1)}s silent β€” benching the route ${Math.round(TRUNCATION_BENCH_MS / 1000)}s (#1218 Gap 3)`);
        }
        const errorClass = classifyAttemptError(err);
        attempts.push({ platform: route.platform, modelId: route.modelId, keyOrdinal: keyOrdinal(route), errorClass });
        traceAttempt(errorClass, err);
        lastError = err;
        // A still-exempt skipBench failure (format ignored, hidden-reasoning
        // truncation under the streak limit) is model behavior, not provider
        // health β€” recordRetryableFailure skipped the cooldown/penalty for it,
        // and it must not count toward the "pool looks unhealthy" breaker
        // either: three prose answers to a json_schema request say nothing
        // about whether candidate four is up. Once the empty-completion streak
        // lifts the exemption (#751), the failure counts everywhere.
        if (!exempt && stopIfBreakerTripped()) return;
        continue;
      }
      traceAttempt(classifyAttemptError(err), err);
      hooks.onFatal(route, err, attempt);
      return;
    }

    // Enforce the dispatch contract: 'done'/'committed' mean the response is
    // finished. Anything else (a stray `return` in an adapter) would silently
    // swallow the request, so fail loudly. Deliberately OUTSIDE the try/catch:
    // the violation message embeds route.modelId, and a model id containing a
    // digit run like "2503" would match a retryable-error substring and make
    // the loop re-dispatch the buggy adapter until exhaustion. Routing straight
    // to onFatal renders an immediate 502 with no retryability classification.
    if (outcome !== 'done' && outcome !== 'committed') {
      const violation = new Error(
        `fallback-loop dispatch contract violation on ${route.platform}/${route.modelId}: ` +
        `expected 'done' or 'committed', got ${JSON.stringify(outcome)}`,
      );
      console.error('[FallbackLoop]', violation.message);
      hooks.logFailure(route, violation, attempt);
      traceAttempt('error', violation);
      hooks.onFatal(route, violation, attempt);
      return;
    }
    // Terminal attempt: 'done' produced the response; 'committed' means the
    // stream had already flushed bytes when the attempt ended (mid-stream
    // error or pre-commit disconnect) β€” the parent row carries the specifics.
    traceAttempt(outcome === 'done' ? 'ok' : 'committed');
    return;
    } finally {
      if (hedgeTimer) clearTimeout(hedgeTimer);
      route.release?.();
    }
  }

  hooks.onExhausted(
    exhaustedRetryError(lastError, maxRetries, { attempts }),
    { attempts, timedOut: false },
  );
}