File size: 94,893 Bytes
6d60378
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
// Package upstream 封装对 CodeBuddy 上游(chat / billing / auth)的全部 HTTP 调用,
// 以及错误分类(驱动 pool 冷却状态机)。
package upstream

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
	"github.com/linguo2625469/workbuddy2api-panel/internal/logfmt"
)

// ErrKind 错误分类,pool 据此决定冷却时长。
type ErrKind int

const (
	ErrNone           ErrKind = iota // 成功
	ErrHardCredit                    // 余额不足(402 或 body 关键词)→ 长冷却
	ErrSoftRate                      // 429 软限流 → 短冷却
	ErrSessionDead                   // 401 + 12153 offline session 失效 → 禁用
	ErrNotFound                      // 404 上游偶发 → 短冷却,不累计错误计数(防雪崩)
	ErrServer                        // 5xx 上游故障
	ErrContentBlocked                // 内容策略拦截(400 + 审核文案)→ 不罚账号,走降级重试
	ErrBadParams                     // 请求体解析失败(400 + Unmarshal chat params failed / 11101)→ 请求级错误:不罚号、不轮转,末端 400 透传原文
	ErrAccountFault                  // 账号级授权/配额故障(11140 request illegal / 14017 trial not activated)→ 冷却轮换,不无限重试
	ErrModelBlocked                  // 11102「该后端无此模型」→ (账号,模型) 负缓存避让,切模型/切账号
	ErrWafBlock                      // 403 + 非业务信封体(APISIX WAF 拦截页/空体)→ 账号软冷却 + 抖动退避
	ErrPromptTooLong                 // 11115「prompt is too long」→ 请求级错误(上下文超限是请求的问题非账号的问题):不罚号、不轮转,末端透传原文
	ErrImageInvalid                  // 图片请求格式/数据无效 → 请求级错误:不罚号、不轮转,末端透传原文
	ErrClient                        // 其他 4xx / 业务错误
)

func (k ErrKind) String() string {
	switch k {
	case ErrHardCredit:
		return "hard_credit"
	case ErrSoftRate:
		return "soft_rate"
	case ErrSessionDead:
		return "session_dead"
	case ErrNotFound:
		return "not_found"
	case ErrServer:
		return "server"
	case ErrContentBlocked:
		return "content_blocked"
	case ErrBadParams:
		return "bad_params"
	case ErrModelBlocked:
		return "model_blocked"
	case ErrWafBlock:
		return "waf_block"
	case ErrPromptTooLong:
		return "prompt_too_long"
	case ErrImageInvalid:
		return "image_invalid"
	case ErrAccountFault:
		return "account_fault"
	case ErrClient:
		return "client"
	default:
		return "none"
	}
}

// Error 带分类的上游错误。
type Error struct {
	Kind   ErrKind
	Status int
	Msg    string
	// RetryAfter 上游明示的等待时长(Retry-After 秒 / retry-after-ms /
	// x-ratelimit-reset 头解析,见 ParseRetryAfter)。零值 = 上游未明示,
	// 冷却时长回落调用方计算值。挂载点选在 Error 信封:Kind 决定「罚不罚」,
	// RetryAfter 决定「罚多久」,同为上游响应的一等公民。
	RetryAfter time.Duration
}

func (e *Error) Error() string {
	return fmt.Sprintf("upstream %s (http %d): %s", e.Kind, e.Status, e.Msg)
}

// hardMarkers 余额不足关键词(小写比较 + 中文原文比较双通道)。
var hardMarkers = []string{
	"insufficient credit", "no credit", "credit exhausted", "credits exhausted", "out of credit",
	"quota exceeded", "quota exhaust", "payment required", "credit not enough",
	"not enough credit",
	"积分不足", "额度不足", "余额不足", "积分用完", "额度用尽", "没有积分",
}

// softRateMarkers 限流/节流关键词(小写比较 + 中文原文比较双通道)。
// 上游在状态码非 429 时也会返回限流语义(如 200 + code 11140
// "The model provider is rate-limiting requests."、400 + "rate limit"),
// 此类响应若不识别,账号既不被冷却也不喂熔断,下次请求仍会被选中(issue #28)。
//
// 词表按子串匹配,宁缺毋滥:只收录明确指向「请求速率/模型用量被节流」的措辞。
// 连字符形式(rate-limiting / rate-limited)需单列——Contains 不跨 '-'。
// "too many" 会命中 "too many tokens" 这类客户端参数错误,代价是该号被软冷却
// 一个 SoftCooldown(默认 60s)后自愈,远小于漏判限流导致反复选中同一号的代价。
var softRateMarkers = []string{
	"rate limit", // rate limit / rate limits / rate limiting
	"rate-limiting",
	"rate-limited",
	"too many requests",
	"too many",
	"usage limit", // usage limit reached / model usage limit exceeded(用量节流,非计费余额)
	"请求过于频繁", "限流",
}

var sessionDeadMarkers = []string{"Offline user session not found", "12153"}

// accountFaultMarkers 账号级授权/配额故障关键词(大小写不敏感子串匹配)。
//
// 定位:这类错误是**账号本身状态**决定的本机故障,不是请求格式、不是临时限流、
// 也不是内容误报——继续重试只会反复刷上游风控/配额检查,必须把该账号冷却轮换。
//   - "request illegal"(code 11140)→ 上游 auth/auth_forbidden,账号级授权风控,
//     需重新 OAuth 登录才能恢复,短冷却只能阻止继续送死。
//   - code 14017("trial not activated" / "The trial version is not yet activated")→
//     上游 quota/quota_not_activated,register 未完成的试用未激活账号,同样账号级。
//
// 注意 11140 **不能**按 code 判定:该 code 也承载模型级限流文案("The model provider
// is rate-limiting requests."),那种场景必须保持 ErrSoftRate(下方 softRateMarkers
// 后判定)。故此处只收 msg 关键词 "request illegal"(auth_forbidden 的真实文案)。
// 14017 文案唯一(无软限流歧义),可安全收录。
var accountFaultMarkers = []string{
	"request illegal",
	"trial not activated",
	"trial version is not yet activated",
}

// contentBlockedMarkers 内容策略拦截关键词(大小写不敏感子串匹配)。
//
// 定位:上游按逐字精确指纹审核,system 来源的模板句(如 Claude Code/Codex
// 注入指令)触发 HTTP 400 + 以下文案。这是「误报」(合法流量被审核误杀),
// 非账号问题——该账号余额健康、未限流、session 未死,故 ErrContentBlocked
// 在 applyErrorPolicy 中不罚账号(无冷却/熔断/NoteError),改由网关降级重试。
var contentBlockedMarkers = []string{
	"blocked by security policy",
	"unapproved channel",
	"illegal api invocation",
}

// badParamsMarkers 请求体解析失败关键词(issue #41 连带):HTTP 400 + 上游
// "Unmarshal chat params failed..."(code 11101)。这是"发给上游的 body 有问题",
// 与账号健康无关——不罚号,但仍轮转(commit B)。
var badParamsMarkerMsg = "Unmarshal chat params failed"

// invalidImageMarkers 图片请求格式/数据无效(HTTP 400)的**文案**形态。这类错误由
// 请求内容决定,不是账号问题:换账号不会改变同一 body 的解析结果。上游常见形态包括
// `Parse message failed: invalid image_url content`、invalid_image_data、
// `replace the image`。
//
// 业务码 11135 不放在这里:code 判定必须容忍 JSON 空白(`"code": 11135`),
// 字面量 marker 只能覆盖紧凑形态,故统一走 codeMarker(见 Classify 的 400 分支,
// 与 hint.go 的 isInvalidImageData 同口径;上游 5d5223d 的 Copilot review 修复)。
var invalidImageMarkers = []string{
	"invalid image_url content",
	"invalid_image_data",
	"replace the image",
}

// 定位:上下文超限是**请求的问题不是账号的问题**——同一个 body 换任何账号发都会
// 超限,与 WAF fail-fast 同哲学(确定与账号无关的错误不罚号不轮转,白白浪费健康号
// 的请求配额)。marker 双通道:
//   - `"code":11115`:业务信封 code 字段(JSON 空格容差;`"code":"11115"` 字符串
//     形态也命中);
//   - "prompt is too long":msg 文案(大小写不敏感)。
//
// 只在 400/404/413 请求级状态码上判(429+11115 概率极低且属限流语义优先,
// 5xx 属服务端故障优先)。误判代价(好 body 被归 prompt_too_long):不罚号 +
// 不轮转 + 透传原文,客户端看到上游原文可自行排查,代价可控。
var promptTooLongMarkers = []string{
	`"code":11115`,
	`"code": 11115`,
	`"code":"11115"`,
	"prompt is too long",
}

// isPromptTooLongStatus 11115 只在请求级 4xx 上判(见 promptTooLongMarkers 注释)。
func isPromptTooLongStatus(status int) bool {
	return status == http.StatusBadRequest || status == http.StatusNotFound ||
		status == http.StatusRequestEntityTooLarge
}

// alreadyCheckinMarkers "今天已签到"关键词(上游对重复签到返回 code!=0,
// 实测 code=10001/14001 "今天已签到"/"今日已签到")。只对 *Error.Msg 做包含匹配,
// 网络层/解析层错误不在此识别(见 IsAlreadyCheckin)。
var alreadyCheckinMarkers = []string{"已签到", "already"}
var badParamsMarkerCode = `"code":11101`

// softRateResetLoc 上游 429 6004 文案中的重置时间固定按 UTC+8 解释(上游文案如此,
// 与容器时区无关)。
var softRateResetLoc = time.FixedZone("UTC+8", 8*60*60)

// SoftRateResetLoc 暴露重置时间的固定时区(供测试构造/断言同一时区口径)。
func SoftRateResetLoc() *time.Location { return softRateResetLoc }

// modelRateLimitCode 明确指向「模型级 429 限流」的业务 code。
// 上游用它表达"该模型的使用量超限"(code 6004,msg 带「将在 … 重置」),
// 而不是账号整体被限流——账号健康,只是这个模型此刻被限(issue #31)。
const modelRateLimitCode = "6004"

// softRateResetPatternCN/EN 匹配重置文案(CN「将在 … 重置」/ global 域英文
// "reset at <固定格式时间>"),捕获中间的时间串。
const softRateResetPatternCN = `将在 (.+?) 重置`
const softRateResetPatternEN = `(?i)reset at (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})`

// 限流判定正则预编译为包级 var:IsModelRateLimit / ParseRateReset 在每次错误
// 分类、每个限流 body 上调用,函数体内 MustCompile 是纯浪费;错误风暴(429
// 轰炸)时尤甚。模式串均为纯常量。regexp 并发安全(匹配只读),无需额外锁。
var (
	reModelRateLimit  = regexp.MustCompile(`"code"\s*:\s*"?` + modelRateLimitCode + `"?`)
	reSoftRateResetCN = regexp.MustCompile(softRateResetPatternCN)
	reSoftRateResetEN = regexp.MustCompile(softRateResetPatternEN)
)

// softRateTimeLayout 上游重置时间的格式(无时区后缀;时区固定 UTC+8)。
const softRateTimeLayout = "2006-01-02 15:04:05"

// IsModelRateLimit 报告 429 body 是否明确指向模型级限流(业务 code 6004)。
// 用于区分"账号级软限流"(按账号冷却)与"模型级用量限流"(切模型即可用)。
func IsModelRateLimit(body string) bool {
	// `"code":6004` / `"code": 6004` / `"code":"6004"` 均可命中(JSON 空格容差)。
	return reModelRateLimit.MatchString(body)
}

// modelBlockCode 明确指向「该后端无此模型」的业务 code。
const modelBlockCode = "11102"

// modelBlockMsgMarker 11102 答复的确定性文案(官方 error message 固定短语)。
// 只收这个窄短语,不收 "model ... not found" 宽正则——后者会误伤其他业务的
// not found 措辞。
const modelBlockMsgMarker = "service info not found"

// ModelBlockReason 11102 负缓存条目在 pool.modelCooldowns 里的 reason 前缀。
// handler 写 BlockModelBackoff;pool.BlockModelClear 按 "11102" 前缀识别条目
// (与 6004 条目的 "6004 model rate limit" reason 互不干扰)。
const ModelBlockReason = "11102 model not available"

// IsModelBlocked 报告 body 是否是「该后端无此模型」(11102) 的确定性答复。
//
// 只比对 code/msg 等独立字段,绝不做整段文本子串匹配:错误体还带 requestId 等字段,
// 拿整段文本匹配会把 "11102" 撞在 ID 上、误避让一个本来能用的模型。判定 =
// code 字段精确等于 "11102",或 msg/message 字段命中窄短语 "service info not
// found"(两者任一命中即真)。只看 400/404:429 带 11102 属限流语义。
// 字段遍历覆盖顶层与 error 子对象两层。
func IsModelBlocked(status int, body string) bool {
	if (status != http.StatusBadRequest && status != http.StatusNotFound) || body == "" {
		return false
	}
	// 轻量预检:body 既无 "11102" 又无 marker 时直接短路(大多数 4xx 零分配返回)。
	if !strings.Contains(body, modelBlockCode) && !strings.Contains(strings.ToLower(body), modelBlockMsgMarker) {
		return false
	}
	var root map[string]any
	if err := json.Unmarshal([]byte(body), &root); err != nil {
		return false
	}
	nodes := []map[string]any{root}
	if inner, ok := root["error"].(map[string]any); ok {
		nodes = append(nodes, inner)
	}
	code, msg := "", ""
	for _, node := range nodes {
		for _, key := range []string{"code", "errCode", "error_code"} {
			if v, ok := node[key]; ok && v != nil && code == "" {
				code = strings.TrimSpace(fmt.Sprint(v))
			}
		}
		for _, key := range []string{"msg", "message"} {
			if v, ok := node[key].(string); ok && v != "" && msg == "" {
				msg = strings.TrimSpace(v)
			}
		}
	}
	if code == modelBlockCode {
		return true
	}
	return strings.Contains(strings.ToLower(msg), modelBlockMsgMarker)
}

// hasBusinessCode reports whether a JSON error envelope contains an exact
// business code in a field named "code". Upstream envelopes vary between
// top-level and nested error/data objects, so walk the decoded structure.
func hasBusinessCode(body, want string) bool {
	var root any
	if err := json.Unmarshal([]byte(body), &root); err != nil {
		return false
	}
	var walk func(any) bool
	walk = func(value any) bool {
		switch node := value.(type) {
		case map[string]any:
			if code, ok := node["code"]; ok && strings.TrimSpace(fmt.Sprint(code)) == want {
				return true
			}
			for _, child := range node {
				if walk(child) {
					return true
				}
			}
		case []any:
			for _, child := range node {
				if walk(child) {
					return true
				}
			}
		}
		return false
	}
	return walk(root)
}

// hasBusinessEnvelope 报告错误 body 是否携带上游业务信封形态(JSON 且含
// `"code":` 或 `"msg":` 字段)。WAF 403 判定(IsWafBlocked)用「无业务信封」
// 区分 APISIX WAF 拦截页(HTML/空体/纯文本)与上游业务层 403(带 code/msg
// 信封,正常走既有分类)。JSON 解析不做:信封存在性只需字段名命中——
// 畸形 JSON 但含 `"msg":` 字样仍按业务响应保守处理(宁漏判 WAF 也不误罚
// 业务 403,后者有各自的权威分类)。
func hasBusinessEnvelope(body string) bool {
	return strings.Contains(body, `"code":`) || strings.Contains(body, `"msg":`)
}

// IsWafBlocked 报告 403 响应是否为 WAF 拦截形态:HTTP 403 且 body 无业务信封
// (无 `"code":`/`"msg":` JSON 字段——HTML 拦截页、空体、纯文本均命中)。
// 带业务信封的 403(11140 request illegal / 11128 等)仍走既有分类链。
// 403 含 accountFault 文案的维持现状(ErrAccountFault),由 Classify 的规则序保证。
func IsWafBlocked(status int, body string) bool {
	return status == http.StatusForbidden && !hasBusinessEnvelope(body)
}

// retryAfterHeaderCandidates 冷却时长优先解析的响应头候选序列:
// retry-after(秒,RFC 7231)/ retry-after-ms(毫秒)/ x-ratelimit-reset
// (epoch 秒或毫秒,取 now+ 剩余量)。大小写不敏感(http.Header.Get 已归一)。
var retryAfterHeaderCandidates = []string{"Retry-After", "Retry-After-Ms", "X-Ratelimit-Reset"}

// retryAfterSanity 解析结果的上限(超过视为上游异常值丢弃,回落本地计算),
// 与 pool 的 softRateMax 默认 2h 同量级。
const retryAfterSanity = 2 * time.Hour

// ParseRetryAfter 从限流/拦截响应头解析上游明示的等待时长:
// 依次尝试 Retry-After(整数秒)→ retry-after-ms(整数毫秒)→
// x-ratelimit-reset(纯数字按 epoch 秒/毫秒推断;HTTP-Date 形态不支持——
// 上游族实践发的是数字)。任一头缺失/非法/非正/超上限则尝试下一头;
// 全部不可用返回 false(调用方回落既有计算值,绝不臆造等待时长)。
func ParseRetryAfter(h http.Header) (time.Duration, bool) {
	for _, name := range retryAfterHeaderCandidates {
		v := strings.TrimSpace(h.Get(name))
		if v == "" {
			continue
		}
		if !isAllDigits(v) {
			continue // 非纯数字(如 HTTP-Date)不解析,宁缺毋滥
		}
		n, ok := parseRetryNumber(v, name)
		if !ok {
			continue
		}
		if n <= 0 || n > retryAfterSanity {
			continue // 非正/异常大:丢弃(回落本地计算)
		}
		return n, true
	}
	return 0, false
}

// isAllDigits 报告 s 是否为纯数字(前置快筛,免 strconv 之后再判语义)。
func isAllDigits(s string) bool {
	if s == "" {
		return false
	}
	for _, r := range s {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}

// parseRetryNumber 按头名口径把纯数字串折算成时长。x-ratelimit-reset 是
// epoch 时刻而非时长:秒口径(10 位)与毫秒口径(13 位)都按「now+ 该时刻
// 的剩余量」折算,已在过去则不可用。位数不足(8 位以下)无法判定 epoch
// 语义的丢弃(宁缺毋滥:短串多半是序号之类的误用头)。
func parseRetryNumber(v, headerName string) (time.Duration, bool) {
	// 上限 16 位防 int64 溢出(超过 epoch 毫秒的现实量级必非法)。
	if len(v) > 16 {
		return 0, false
	}
	var n int64
	for _, r := range v {
		n = n*10 + int64(r-'0')
	}
	switch headerName {
	case "Retry-After":
		// 先做上限校验再乘 time.Second:16 位数字乘 1e9 会溢出 int64 回绕成
		// 小正数,进而通过调用方的 retryAfterSanity 校验被当作合法等待时长。
		if n > int64(retryAfterSanity/time.Second) {
			return 0, false
		}
		return time.Duration(n) * time.Second, true
	case "Retry-After-Ms":
		if n > int64(retryAfterSanity/time.Millisecond) {
			return 0, false
		}
		return time.Duration(n) * time.Millisecond, true
	default: // X-Ratelimit-Reset:epoch → 剩余量
		sec := n
		if len(v) >= 12 { // 毫秒口径(13 位);11 位边界按秒(误判代价是多算 1000 倍)
			sec = n / 1000
		}
		remain := time.Until(time.Unix(sec, 0))
		return remain, true
	}
}

// ParseRateReset 从任何限流响应 body 里统一解析「将在 … 重置」时间(上游 UTC+8 文案)。
// 成功返回解析出的**墙钟时刻**(按 UTC+8 解释),失败返回零值 + false。
//
// 是否走模型级豁免、时日对齐到 until 还是 modelCooldowns,由冷却决策侧(pool)按
// IsModelRateLimit 判定,本函数只负责「把上游明说的恢复时刻抽出来」。没有时间文案
// 的限流也照常由调用方退回有界退避(绝不臆造时间)。
func ParseRateReset(body string) (time.Time, bool) {
	// CN 文案优先;global 域 429 body 是英文形态("will reset at YYYY-MM-DD HH:MM:SS
	// UTC+8"),此前只认中文 → global 限流解析不到恢复时刻,退回有界退避基数反复
	// 翻倍(修「global 域冷却指数翻倍」)。英文正则锚定固定格式时间,自然语言
	// ("reset at the end of the day")不匹配。
	m := reSoftRateResetCN.FindStringSubmatch(body)
	if len(m) < 2 {
		m = reSoftRateResetEN.FindStringSubmatch(body)
	}
	if len(m) < 2 {
		return time.Time{}, false
	}
	ts := strings.TrimSpace(m[1])
	ts = strings.TrimSuffix(ts, " UTC+8") // 去掉后缀,固定按 softRateResetLoc 解释
	t, err := time.ParseInLocation(softRateTimeLayout, ts, softRateResetLoc)
	if err != nil {
		return time.Time{}, false
	}
	return t, true
}

// Classify 按 HTTP 状态码 + body 判定错误类别。
//
// 判定顺序自「严」到「宽」,每层的先后都有语义依据:
//  0. 11102(IsModelBlocked)——「该后端无此模型」确定性答复,语义最具体,最先判
//     (只认 400/404,429+11102 属限流语义走第 4 层)。
//  1. 402 —— 真正的计费余额耗尽状态码,最严、最不可自愈,最先判。
//  2. sessionDeadMarkers —— 需要人工重登的终态。若 401 body 同时含 "12153" 与
//     "rate limit"(如网关错误页混排),归 session_dead:短冷却救不活失效 session,
//     误判为限流会让该死号留在池中反复被选中;且此层 marker 是精确词(12153 等),
//     比限流层的大范围子串更具体,具体优先于宽泛。
//  3. accountFaultMarkers —— 账号级授权/配额故障(11140 request illegal auth 风控、
//     14017 trial not activated register 未完成)。必须先于 status==429 判定:
//     14017 常带 429 状态码,若落到 status==429 会误归 soft_rate("限流"语义不符:
//     限流可指数退避等自愈,账号级故障等不来)。11140 的 model 级限流变体
//     (rate-limiting 文案)因 marker 不含该文案而天然落到 softRateMarkers 层,
//     不受影响。
//  4. 429 + code 14018 —— 明确的账号积分耗尽,归 ErrHardCredit(issue #175)。
//     只认结构化业务码,不靠可能跨计费/限流两界的文案猜测。
//  5. status==429 —— 限流状态码兜底(先于 hardMarkers):429 body 高频携带
//     "quota exceeded"/"额度不足" 等跨计费/限流两界的措辞,hardMarkers 先判会把
//     限流误归 ErrHardCredit 硬冷却到次日 04:00,白扔号约 12h。状态码是比关键词
//     更权威的信号;真正的余额耗尽由 402(第 1 层)或 14018(第 4 层)捕获,
//     非 429 状态码的 quota 措辞仍走下方 hardMarkers(第 6 层)。
//  6. hardMarkers —— 非 429 响应携带计费关键词(200 业务信封 / 403 信封等)。
//  7. softRateMarkers —— 非 429 状态码携带限流文案(issue #28 修复点)。
//     位于此处可覆盖 200/400/403/5xx 各状态码。
//  8. 11115 —— 「prompt is too long」请求级语义:判在 404/5xx 与通用 4xx 兜底
//     之前(404 上打 11115 若落 ErrNotFound 会误冷却账号——上下文超限与账号无关)。
//  9. 404 / 5xx —— 与限流无关的常规分类。
//  10. IsWafBlocked —— 403 且无业务信封(HTML 拦截页/空体/纯文本):APISIX WAF
//     拦截形态。判在通用 4xx 兜底**之前**:此前该形态落 ErrClient → 只换号不罚 →
//     连环 403。带业务信封的 403 已被上方各层捕获,走不到本层。
//  11. 内容策略/参数错误/其他 4xx —— 通用兜底。
func Classify(status int, body string) ErrKind {
	// 11102「该后端无此模型」须最先判:它是「模型在后端不存在」的确定性答复,语义比
	// 计费/限流都更具体——若不先判,msg 里的 "service info not found" 会被更宽的
	// 4xx 兜底归为 ErrClient(只换号不避让),该坏号会留在池内反复被选中。
	// 只认 400/404(见 IsModelBlocked),429+11102 落下方 status==429 层走限流语义。
	if IsModelBlocked(status, body) {
		return ErrModelBlocked
	}
	// 402:真正的计费余额耗尽状态码,最严、最不可自愈,最先判。
	if status == http.StatusPaymentRequired {
		return ErrHardCredit
	}
	lower := strings.ToLower(body)
	// sessionDead / accountFault 先于 status==429:账号级终态等不来自愈,限流状态码
	// 不得掩盖它们(429+14017 必须 accountFault,401+12153 混排 "rate limit" 必须
	// sessionDead——此层 marker 是精确词,比限流层的大范围子串更具体,具体优先于宽泛)。
	for _, m := range sessionDeadMarkers {
		if strings.Contains(body, m) {
			return ErrSessionDead
		}
	}
	for _, m := range accountFaultMarkers {
		if strings.Contains(lower, strings.ToLower(m)) || strings.Contains(body, m) {
			return ErrAccountFault
		}
	}
	// 14018 是明确的账号积分耗尽业务码。它必须先于通用 429 兜底,否则会被误判为
	// 可自愈的软限流并在全池冷却时反复兜底选中(issue #175)。仅按结构化 code
	// 判定;无该 code 的 "credits exhausted" 文案仍保持普通 429 的软限流语义。
	if status == http.StatusTooManyRequests && hasBusinessCode(body, "14018") {
		return ErrHardCredit
	}
	// status==429 先于 hardMarkers:限流响应 body 高频携带 "quota exceeded"/
	// "额度不足" 等跨计费/限流两界的措辞,hardMarkers 先判会把限流误归
	// ErrHardCredit 硬冷却到次日 04:00,白扔号约 12h。状态码是比关键词更权威的
	// 信号:上游既然给了 429,就按限流语义处理(宁可短冷却自愈,不可长冷却弃号);
	// 真正的余额耗尽由 402(上层)或 14018(上层)捕获,非 429 状态码的 quota
	// 措辞仍走下方 hardMarkers(历史语义不变)。
	if status == http.StatusTooManyRequests {
		return ErrSoftRate
	}
	for _, m := range hardMarkers {
		if strings.Contains(lower, strings.ToLower(m)) || strings.Contains(body, m) {
			return ErrHardCredit
		}
	}
	for _, m := range softRateMarkers {
		if strings.Contains(lower, strings.ToLower(m)) || strings.Contains(body, m) {
			return ErrSoftRate
		}
	}
	// 11115「prompt is too long」:判在 404/5xx/WAF/内容策略/参数错误/通用 4xx
	// 之前——请求级语义最具体(上下文超限),须先于宽泛的状态码兜底(404 兜底会
	// 误归 ErrNotFound 只冷却不透传;ErrClient 只换号,浪费健康号配额)。
	if isPromptTooLongStatus(status) {
		for _, m := range promptTooLongMarkers {
			if strings.Contains(body, m) || (m != strings.ToLower(m) && strings.Contains(lower, strings.ToLower(m))) {
				return ErrPromptTooLong
			}
		}
	}
	if status == http.StatusNotFound {
		return ErrNotFound
	}
	if status >= 500 {
		return ErrServer
	}
	// WAF 403(无业务信封的拦截形态):判在内容策略/参数错误/通用 4xx 之前——
	// 这些层只认带文案的 body,WAF 空体/HTML 永远不会命中它们的 marker,
	// 但落 ErrClient 兜底的代价是「只换号不罚」(连环 403 根因),必须在兜底前分流。
	// 带信封的 403 在上方各层已有权威分类,不受影响。
	if IsWafBlocked(status, body) {
		return ErrWafBlock
	}
	// 图片格式/数据错误是确定性的请求级错误:同 body 换账号结果不变,直接
	// fail-fast,避免把健康账号轮转一遍后仍把最终 503 返回给客户端。
	// 11135 业务码走 codeMarker(JSON 空白容差),文案走 invalidImageMarkers。
	if status == http.StatusBadRequest && codeMarker(lower, "11135") {
		return ErrImageInvalid
	}
	if status == http.StatusBadRequest {
		for _, m := range invalidImageMarkers {
			if strings.Contains(lower, m) {
				return ErrImageInvalid
			}
		}
	}
	// 内容策略拦截(HTTP 400 + 审核文案):判在通用 ErrClient 之前。
	// 这是误报信号,不罚账号,由网关降级重试处理(见 handler.applyErrorPolicy)。
	if status >= 400 {
		for _, m := range contentBlockedMarkers {
			if strings.Contains(lower, m) {
				return ErrContentBlocked
			}
		}
		// 请求体解析失败(HTTP 400 + Unmarshal chat params failed / code 11101):
		// 这是"发给上游的 body 有问题"。网关侧截断已由 413 消灭(issue #41 commit A),
		// 剩余来源是客户端 JSON 本身畸形——换了账号照样 400,不该罚号(白白冷却好号)。
		// 归 ErrBadParams:不冷却/不熔断/不计错,且**不轮转**——11101 发生在上游解析
		// 请求体阶段,还没走到模型路由,所以"不同账号可能有不同模型权限"其实是
		// 11102(ErrModelBlocked)的理由,那里已有 (账号,模型) 负缓存避让。
		if strings.Contains(body, badParamsMarkerMsg) || strings.Contains(body, badParamsMarkerCode) {
			return ErrBadParams
		}
		return ErrClient
	}
	// HTTP 200 但业务 code 非 0 且含余额关键词的情况已被上面 hardMarkers 捕获。
	return ErrNone
}

// apiEnvelope 上游统一信封。
type apiEnvelope struct {
	Code int             `json:"code"`
	Msg  string          `json:"msg"`
	Data json.RawMessage `json:"data"`
}

// Client 上游 HTTP 客户端。Base 字段可覆盖便于测试。
type Client struct {
	HTTP *http.Client

	// ChatHTTP 聊天 SSE 专用 client:无总时长上限(Timeout=0),首字节由
	// Transport.ResponseHeaderTimeout 约束,流中空闲由 IdleTimeout 约束。
	// 与 HTTP 共享同一个 *http.Transport 实例,连接池不重复。
	ChatHTTP *http.Client

	// HeaderTimeout 聊天 SSE 首字节前(响应头)超时;<=0 表示未设置(回落 HTTP.Timeout)。
	HeaderTimeout time.Duration
	// IdleTimeout 聊天 SSE 流中空闲超时;<=0 表示禁用空闲监控。
	IdleTimeout time.Duration

	// effortsMu/efforts 缓存各模型 supportedEfforts(FetchModels 刷新),供请求体 effort 降级。
	// 按 realm 分层桶(cn/global):同模型名跨域探测的 effort 集合可能不同,
	// 混桶会互相污染(C-2)。
	effortsMu sync.RWMutex
	efforts   map[string]map[string][]string
	// defaultEfforts 缓存各模型 reasoning.defaultEffort(FetchModels 刷新),供
	// thinking.go 补档:缺显式 effort 时优先用模型声明默认档,空串回退硬编码 high。
	// 与 efforts 同 realm 分层桶(同 C-2 隔离原则),共用 effortsMu。
	defaultEfforts map[string]map[string]string
	// modelRates 缓存各模型当前生效积分倍率(规范化数值,如 "0.5")。
	// 与 efforts 共用 realm 分层和锁;每次成功刷新模型目录时整体替换对应域。
	modelRates map[string]map[string]string

	// globalModels 缓存 global 模型名目录探测结果(成功 ∩ 静态 overlay;
	// 1h TTL + 5min 负缓存),见 global_models.go。按实例持有,测试新建 Client 即隔离。
	globalModels fetchGlobalModelsCache

	// SanitizeFingerprints 出站请求体黑名单指纹脱敏开关(默认 true;false 完全还原)。
	// 面板保存配置热改 + chat 热路径并发读写,用 atomic.Bool 消除数据竞争。
	SanitizeFingerprints atomic.Bool

	// UserAgent 出站 User-Agent 显式覆盖(非空时全路径生效,优先于默认三段式)。
	// 空 = 默认官方形态:chat/refresh/FetchModels 走
	// `WorkBuddy/<ver> WorkBuddy/<ver> CLI/<cliVer>`;billing 走 `WorkBuddy/<ver>`
	// (仅当 client_name 非空)。
	UserAgent string

	// ClientVersion WorkBuddy 客户端版本段(出站 UA 的 `WorkBuddy/<ver>` + X-IDE-Version)。
	// 空 = 内置默认(对齐官方 5.5.4 分发包)。
	ClientVersion string

	// CliVersion 出站 UA 中 `CLI/<ver>` 段版本。空 = 内置默认(官方内置 CLI 2.137.1)。
	CliVersion string

	// ClientName 用量归属头取值(X-Product / X-IDE-Name / X-IDE-Type / X-IDE-Version)。
	// 空 = 旧行为:X-Product="SaaS",不设 X-IDE-*(向后兼容,不突变归因)。
	ClientName string

	// PassthroughIP 是否透传客户端 IP 给上游(X-Forwarded-For/X-Real-IP 首段)。
	// 缺省 false(反代安全边界);handler 在 chat 路径按请求把 clientIP 传入 ChatStream。
	PassthroughIP bool

	// DeviceToken 设备风控 Token(X-Device-Token 头)全局兜底来源:config upstream.device_token。
	// 解析优先级:auth.Auth.DeviceToken > DeviceToken(config)> DeviceTokenFile(文件)。
	DeviceToken string

	// DeviceTokenFile 设备 token 文件路径兜底(宿主落盘的桌面端 token,5 分钟读取缓存)。
	DeviceTokenFile string

	ChatBaseCN    string
	BillingBaseCN string
	// WebBaseCN 官网(workbuddy.cn)域:部分「任务领奖」类接口只在此域提供
	// (Web 成长中心用;CLI 域 copilot.tencent.com 的同名路径返回 400)。
	WebBaseCN string

	// ChatBaseGlobal / BillingBaseGlobal 国际版(global realm)上游 base。
	// 空 = 缺省默认 https://www.workbuddy.ai(D5)。
	ChatBaseGlobal    string
	BillingBaseGlobal string

	// GlobalEnabled 是否启用 global realm 路由(config global.enabled,缺省 true)。
	// false 时即便用户 auth 写了 realm=global 也**不**路由到 global base——
	// chatBase/billingBase 返回 CN base,路径也走 CN(双保险,与 auth.Realm() 的开关闸呼应)。
	GlobalEnabled bool
}

// New 生产默认值。Transport 由 newTransport() 集中构造(连接层加固:真正禁 h2 /
// TLS 握手超时 / 短 keepalive 探测 / 失败清池,参数见 transport.go——吸收上游
// kongjianguan 4 连击实测经验)。
func New() *Client {
	tr := newTransport()
	c := &Client{
		HTTP:          &http.Client{Timeout: 120 * time.Second, Transport: tr},
		ChatHTTP:      &http.Client{Timeout: 0, Transport: tr}, // 无总时长;首字节由 ResponseHeaderTimeout 管
		ChatBaseCN:    "https://copilot.tencent.com",
		BillingBaseCN: "https://www.codebuddy.cn",
		WebBaseCN:     "https://www.workbuddy.cn",
		// GlobalEnabled 缺省 true(与 config global.enabled 缺省 true 一致;纯 CN 部署行为不变:
		// CN 账号恒判 cn,global base 只在 realm=global 的账号上被使用)。
		GlobalEnabled: true,
	}
	c.SanitizeFingerprints.Store(true)
	return c
}

// chatHTTP 返回聊天专用 client;未设置(如测试只注入 HTTP)时回落 HTTP。
func (c *Client) chatHTTP() *http.Client {
	if c.ChatHTTP != nil {
		return c.ChatHTTP
	}
	return c.HTTP
}

// defaultGlobalBase 缺省 global base(D5:config 未覆盖时默认 workbuddy.ai)。
const defaultGlobalBase = "https://www.workbuddy.ai"

// globalChatBase 生效的 global chat base:Client.ChatBaseGlobal 非空取之,否则默认。
func (c *Client) globalChatBase() string {
	if c.ChatBaseGlobal != "" {
		return c.ChatBaseGlobal
	}
	return defaultGlobalBase
}

// globalBillingBase 生效的 global billing base:Client.BillingBaseGlobal 非空取之,否则默认。
func (c *Client) globalBillingBase() string {
	if c.BillingBaseGlobal != "" {
		return c.BillingBaseGlobal
	}
	return defaultGlobalBase
}

// globalOn 报告账号是否路由到 global 上游:GlobalEnabled 开且账号 Realm()==global。
// 双保险:config 开关是第一道闸(上游侧),auth.Realm() 的开关闸是第二道(账号侧)。
func (c *Client) globalOn(a *auth.Auth) bool {
	return c.GlobalEnabled && a != nil && a.Realm() == "global"
}

// 路径常量:CN 与 global 共用的 chat 出站路径(/v2 单路径)。
const chatCompletionsPath = "/v2/chat/completions"

// chatPaths 返回按 realm 的 chat 路径候选序列:
// global → [/v2](#119 固定单路径:/console 挂腾讯云 WAF body 内容规则,反引号
// printf/whoami 等命令执行特征确定性 403;/v2 同 base 不挂该规则,实测等价端点。
// 已知取舍:若上游未来关闭 /v2,global chat 整体不可用——届时应重新启用 /console
// 路径,此注释即"坏了再说"的锚点);cn → [/v2](单元素,现状)。
func (c *Client) chatPaths(a *auth.Auth) []string {
	return []string{chatCompletionsPath}
}

// billing 域端点路径(billingBase + path)。balance/checkin 与 report(report.go)同域,
// 统一走 billingJSON 发请求。
const (
	billingMeterPath   = "/billing/meter/get-user-resource"    // global 首选(国际版无 /v2 前缀)
	dailyCheckinPath   = "/billing/meter/daily-checkin"        // global 首选
	billingMeterPathV2 = "/v2/billing/meter/get-user-resource" // CN 现状 / global fallback
	dailyCheckinPathV2 = "/v2/billing/meter/daily-checkin"
)

// billingMeterPaths 按 realm 返回 billing/meter 域路径候选序列:
// global → [无 /v2, 有 /v2](404 时 fallback);cn → [有 /v2](现状逐字,零回归)。
// 仅作用于 get-user-resource / daily-checkin(/billing/meter/* 族);report /v2/report 不参与,
// 其他 billing 端点(growth 等)路径不含 /billing/meter 前缀,走原常量不受影响。
func (c *Client) billingMeterPaths(a *auth.Auth) []string {
	if c.globalOn(a) {
		return []string{billingMeterPath, billingMeterPathV2}
	}
	return []string{billingMeterPathV2}
}

// checkinMeterPaths 同上,针对 daily-checkin。
func (c *Client) checkinMeterPaths(a *auth.Auth) []string {
	if c.globalOn(a) {
		return []string{dailyCheckinPath, dailyCheckinPathV2}
	}
	return []string{dailyCheckinPathV2}
}

func (c *Client) chatBase(a *auth.Auth) string {
	if c.globalOn(a) {
		return c.globalChatBase()
	}
	return c.ChatBaseCN
}

// prepareBody 组装出站请求体(脱敏开关由 Client.SanitizeFingerprints 控制)。
// realm 为账号 Realm()(cn/global),供 efforts 缓存分桶(跨域 effort 集合不互相污染)。
func (c *Client) prepareBody(body []byte, realm, uid, conversationID string) []byte {
	efforts, defs := c.effortsSnapshot(realm), c.defaultEffortsSnapshot(realm)
	if realmKey(realm) == "global" {
		// global 域降级源 = 远端探测桶(权威)∪ 产品静态兜底表(全局 21 名内档位如
		// deepseek-v4.1-flash ['high'])。当前探测桶为空时也按静态表降级,不全程透传
		//(issue #84:往 WorkBuddy 上游发 low/max 非法,须降级到 high)。
		efforts, defs = globalEffortMap(efforts, defs)
	}
	body = PrepareBodyOptWithEffortsAndDefault(body, c.SanitizeFingerprints.Load(), efforts, defs)
	// prompt_cache_key 注入(P0 费用优化,费用降 ~17×):按账号隔离的稳定缓存键,
	// 让同一客户端对同一账号的连续请求命中上游前缀缓存。
	body = InjectPromptCacheKey(body, uid, conversationID)
	return body
}

// effortsSnapshot 返回 effort 能力缓存副本;nil 表示未知(透传不降级)。
func (c *Client) effortsSnapshot(realm string) map[string][]string {
	c.effortsMu.RLock()
	defer c.effortsMu.RUnlock()
	bucket, ok := c.efforts[realmKey(realm)]
	if !ok || len(bucket) == 0 {
		return nil
	}
	cp := make(map[string][]string, len(bucket))
	for k, v := range bucket {
		cp[k] = v
	}
	return cp
}

// defaultEffortsSnapshot 返回指定 realm 的模型 defaultEffort 缓存副本;
// 该域无探测或无声明默认档 → nil(thinking.go 回退硬编码 high)。
func (c *Client) defaultEffortsSnapshot(realm string) map[string]string {
	c.effortsMu.RLock()
	defer c.effortsMu.RUnlock()
	bucket, ok := c.defaultEfforts[realmKey(realm)]
	if !ok || len(bucket) == 0 {
		return nil
	}
	cp := make(map[string]string, len(bucket))
	for k, v := range bucket {
		cp[k] = v
	}
	return cp
}

// realmKey 归一化 efforts 缓存键:cn/global。空 realm 视为 cn(老调用/无前缀模型名)。
func realmKey(realm string) string {
	if realm == "" {
		return "cn"
	}
	return realm
}

func (c *Client) billingBase(a *auth.Auth) string {
	if c.globalOn(a) {
		return c.globalBillingBase()
	}
	return c.BillingBaseCN
}

// webBase 返回官网域(任务领奖类接口;未注入时回落默认)。
// realm 感知:global 账号切国际站 workbuddy.ai,CN 用 workbuddy.cn。
func (c *Client) webBase(a *auth.Auth) string {
	if c.globalOn(a) {
		return defaultGlobalBase
	}
	if c.WebBaseCN != "" {
		return c.WebBaseCN
	}
	return "https://www.workbuddy.cn"
}

// doJSON 发请求并解信封;HTTP 非 2xx 或业务 code != 0 时返回带 body 片段的 *Error。
// body 读失败(连接中断/空闲掐流/截断)返回普通错误(非 *Error)——半截 body 不进
// Classify,不参与账号惩罚(传输层故障不该喂熔断误罚号)。
func (c *Client) doJSON(req *http.Request) (json.RawMessage, error) {
	resp, err := c.HTTP.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		return nil, fmt.Errorf("read body: %w", err)
	}
	if resp.StatusCode >= 400 {
		kind := Classify(resp.StatusCode, string(raw))
		return nil, &Error{Kind: kind, Status: resp.StatusCode, Msg: truncate(string(raw), 200)}
	}
	var env apiEnvelope
	if err := json.Unmarshal(raw, &env); err != nil {
		return nil, fmt.Errorf("parse failed: %w (body: %s)", err, truncate(string(raw), 120))
	}
	if env.Code != 0 {
		kind := Classify(resp.StatusCode, env.Msg)
		if kind == ErrNone {
			kind = ErrClient
		}
		return nil, &Error{Kind: kind, Status: resp.StatusCode, Msg: fmt.Sprintf("code=%d msg=%s", env.Code, truncate(env.Msg, 160))}
	}
	return env.Data, nil
}

// RefreshToken 刷新 access token;成功时更新 a 的字段(缺省值保留旧值),
// 调用方负责 SaveAtomic。全程持 a 锁,防止并发 SaveAtomic 读半更新 token。
// refreshIOTimeout 刷新端点网络 I/O 上限(两段式锁外执行,防上游 hang 长占锁)。
const refreshIOTimeout = 30 * time.Second

// refreshTokenExpiresInMax refresh 响应 expiresIn 的量级上限(10 年,纯防御值:
// 实测 R-D 响应恒 5184000=60d)。超限视为上游脏数据,不写 ExpiresAt(保留旧值),
// 防止 NeedsRefresh 永假导致 token 永不刷新反而真过期失效。
const refreshTokenExpiresInMax = 10 * 365 * 24 * time.Hour

// RefreshToken 刷新 access token;成功时更新 a 的字段(缺省值保留旧值),
// 调用方负责 SaveAtomic。
//
// 并发安全模型(两段式,缩小持锁窗口):
//   - 锁内仅做「读 refreshToken 快照」与「校验未变后写回新 token」两小段内存操作;
//   - 网络 I/O(doJSON)在**锁外**执行,带 30s ctx 超时——避免上游 hang 时长时间
//     独占 a.mu,阻塞同账号的 SaveAtomic / 其他刷新(issue:持锁 120s I/O)。
//   - 写回前重新校验快照一致性:若锁外期间另一 goroutine 已完成刷新(refreshToken
//     已变),本次结果直接采用(新 token 已生效),不再重复写回。
func (c *Client) RefreshToken(a *auth.Auth) error {
	// 第 1 段(锁内):读快照。
	a.Lock()
	rtSnapshot := a.RefreshToken
	atBefore := a.AccessToken
	a.Unlock()
	if strings.TrimSpace(rtSnapshot) == "" {
		return fmt.Errorf("no refreshToken")
	}

	endpoint := c.chatBase(a) + "/v2/plugin/auth/token/refresh"
	ctx, cancel := context.WithTimeout(context.Background(), refreshIOTimeout)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
	if err != nil {
		return err
	}
	// RefreshHeaders 读取 a 的字段(domain/uid 等)注入请求头——需在锁内取快照值,
	// 用一个显式逐字段拷贝的临时 auth 构造头(不拷贝 sync.Mutex,避免 vet copies-lock)。
	a.Lock()
	hdrSnapshot := auth.Auth{
		AccessToken:  a.AccessToken,
		RefreshToken: rtSnapshot,
		ExpiresAt:    a.ExpiresAt,
		Domain:       a.Domain,
		UID:          a.UID,
		EnterpriseID: a.EnterpriseID,
		Nickname:     a.Nickname,
		DeviceToken:  a.DeviceToken,
	}
	a.Unlock()
	c.RefreshHeaders(req, &hdrSnapshot)

	// 网络 I/O(锁外,30s 上限)。
	data, err := c.doJSON(req)
	if err != nil {
		return err
	}
	var tok struct {
		AccessToken  string `json:"accessToken"`
		RefreshToken string `json:"refreshToken"`
		ExpiresIn    int64  `json:"expiresIn"`
		Domain       string `json:"domain"`
	}
	if err := json.Unmarshal(data, &tok); err != nil || tok.AccessToken == "" {
		return fmt.Errorf("refresh_failed: no accessToken in response — re-login required")
	}

	// 第 2 段(锁内):校验快照一致后写回。
	a.Lock()
	defer a.Unlock()
	// 写回守卫是 AND 语义:锁外期间另一刷新已完成 → 两 token 必同时变化(实测 R-D:
	// refresh 响应 accessToken/refreshToken 总是一起 rotate,写回也同时写两个),AND
	// 即「并发刷新已完成」判据;AND 与 OR 在真实形态下等价。唯 OR 会额外放弃的
	// 「只有单 token 变化」(如手工只改 auth 文件一个字段)不构成放弃条件——本次
	// 结果覆盖手工编辑。
	if a.AccessToken != atBefore && a.RefreshToken != rtSnapshot {
		// 锁外期间另一 goroutine 已完成刷新:新 token 已生效,本次结果不必再写
		// (实测 R-E:服务端无 rotation 撤销,并发双刷新拿到的两个新 token 都有效,
		// 后写覆盖先写二者等价可用;提前返回避免无意义覆盖与 ExpiresAt 抖动)。
		return nil
	}
	a.AccessToken = tok.AccessToken
	if tok.RefreshToken != "" {
		a.RefreshToken = tok.RefreshToken
	}
	if tok.Domain != "" {
		a.Domain = tok.Domain
	}
	// preserveExpiry:响应缺 expiresIn 时保留旧过期时间,避免刷新风暴。
	// 实测 R-D 响应恒带 expiresIn=5184000(60d)——缺省分支仅为防御,保留旧值
	// 避免过期判定漂移。同理,超过 10 年的 expiresIn 按脏值处理保留旧值:
	// 实测 JWT exp-iat 与 expiresIn 严格自洽(R-F),超量级值只会是上游脏数据,
	// 照写会把 ExpiresAt 推到荒谬未来 → NeedsRefresh 永假 → token 永不刷新
	// 反而真过期失效。
	if tok.ExpiresIn > 0 && time.Duration(tok.ExpiresIn)*time.Second < refreshTokenExpiresInMax {
		a.ExpiresAt = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix()
	}
	return nil
}

// ChatStream 发 chat 请求并返回原始 SSE body 流(调用方负责 Close)。
// 等价于 ChatStreamContext(context.Background(), ...):不带调用方取消语义。
// 需要客户端断开联动的调用方用 ChatStreamContext 传入请求 ctx。
//
// global realm:先打 /console/chat/completions,404/405 时同一 base 二次换 /v2/chat/completions
// (上游新旧路径分叉,PLAN R9 fallback 顺序)。cn:/v2/chat/completions 现状不变。
func (c *Client) ChatStream(a *auth.Auth, body []byte, clientIP string, meta ChatMeta) (rc io.ReadCloser, status int, respBody []byte, err error) {
	return c.ChatStreamContext(context.Background(), a, body, clientIP, meta)
}

// ChatStreamContext 同 ChatStream,但从 ctx 派生请求 context:调用方(handler)传入
// r.Context() 后,客户端断连/请求取消会立即中断在途上游调用、释放连接与账号在途名额,
// 不再空转到 IdleTimeout。ctx 为 nil 时回落 Background。成功流的 cancel 仍由
// monitorBody 的 Close 接管(reqCtx 取消与显式 Close 任一触发即断)。
//
// 错误路径(≥400 且非 fallback 状态码)除 (status, respBody) 外还返回**已分类的**
// *Error(Kind 信封 + Retry-After 头解析):客户端错误分类在此一次完成,handler
// 不再对 body 二次 Classify(消除「上游分类一次、网关再分类一次」的双路径漂移面),
// Retry-After 也随信封流动。respBody 仍原样返回(错误透传语义:message 透传上游
// 原文)。判定为 ErrNone 的响应(理论上不存在,防御)err 为 nil,handler 按
// respBody 自行兜底。
//
// global chat 自 #119 实测后固定走 /v2(chat 层无 fallback 链;billing 层的 404
// fallback 独立存在,语义不受影响)。ensureConsoleSystem 在 prepareBody 后统一套用
// 全局脚本:首条消息非 system 时前置兜底 system(防 console 域上游 code 11-128;
// #119 后 global 出站固定 /v2,该兜底保留——上游对 /v2 是否需要 system 无实测
// 反证,删了无回滚路径)。
func (c *Client) ChatStreamContext(ctx context.Context, a *auth.Auth, body []byte, clientIP string, meta ChatMeta) (rc io.ReadCloser, status int, respBody []byte, err error) {
	if ctx == nil {
		ctx = context.Background()
	}
	prepared := c.prepareBody(body, a.Realm(), a.UID, meta.ConversationID)
	if c.globalOn(a) {
		prepared = ensureConsoleSystem(prepared)
	}
	// reqCtx 的 cancel 在每个出口显式调用(Do 失败 / ≥400 / 成功分支移交 monitorBody),
	// 循环本身各分支必 return——无循环尾兜底代码(此前外层 var cancel 从未赋值 +
	// 尾部不可达 cancel() 是潜伏 nil-panic,已删;chatPaths 恒非空由构造保证)。
	for _, path := range c.chatPaths(a) {
		endpoint := c.chatBase(a) + path
		req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(prepared))
		if err != nil {
			return nil, 0, nil, err
		}
		c.ChatHeaders(req, a, clientIP, meta)
		// 从调用方 ctx 派生:保留取消传播(父 ctx 取消 → 本 ctx 取消),
		// 同时 monitorBody.Close 仍能独立 cancel 本分支(空闲掐流)。
		reqCtx, cancel := context.WithCancel(ctx)
		req = req.WithContext(reqCtx)
		resp, err := c.chatHTTP().Do(req)
		if err != nil {
			cancel()
			log.Printf("ERR: [upstream] chat_stream acct=%s: transport error: %v", logfmt.Label(a.UID, a.Nickname), err)
			// 传输层失败 → 清空共享连接池的空闲连接(连接层加固):失败连接可能仍
			// 留在空闲池里,下一个请求会继续捡到它——仅靠 IdleConnTimeout 等过期
			// 不够,主动清池才断根。
			roundTripCloseIdle(c.chatHTTP().Transport)
			return nil, 0, nil, err
		}
		if resp.StatusCode >= 400 {
			raw, rerr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
			resp.Body.Close()
			cancel()
			// body 读失败(掐流/截断)→ 传输层错误:半截 raw 不交回调用方进 Classify,
			// 否则 handler 侧 applyErrorPolicy 会按误判分类罚号。
			if rerr != nil {
				log.Printf("ERR: [upstream] chat_stream acct=%s: read body: %v", logfmt.Label(a.UID, a.Nickname), rerr)
				return nil, 0, nil, fmt.Errorf("read body: %w", rerr)
			}
			kind := Classify(resp.StatusCode, string(raw))
			log.Printf("WARN: [upstream] chat_stream acct=%s: upstream %d %s body=%s",
				logfmt.Label(a.UID, a.Nickname), resp.StatusCode, kind, truncate(string(raw), 200))
			// ≥400 直接返回(#119 后 global 单路径 /v2,chat 层无 fallback 链)。
			// 分类一次、随 Kind 信封返回(含 Retry-After 头解析):
			// ErrNone 是防御分支(≥400 不应产生 None),返回原文让 handler 兜底。
			if kind == ErrNone {
				return nil, resp.StatusCode, raw, nil
			}
			ue := &Error{Kind: kind, Status: resp.StatusCode, Msg: truncate(string(raw), 200)}
			if d, ok := ParseRetryAfter(resp.Header); ok {
				ue.RetryAfter = d
			}
			return nil, resp.StatusCode, raw, ue
		}
		// 成功分支:cancel 所有权交给 monitorBody(其 Close 会 cancel);
		// IdleTimeout<=0 时 monitorBody 原样返回底流、无人调 cancel——可接受:
		// 取消传播由 http.Transport 在 body Close / 父 ctx 取消时处理,连接正常清理。
		return monitorBody(resp.Body, c.IdleTimeout, cancel), resp.StatusCode, nil, nil
	}
	panic("unreachable: chatPaths is never empty") // for range 空集时编译器仍要求兜底 return;chatPaths 恒非空(构造保证),永不触达
}

// ModelInfo 动态模型信息(含 maxInputTokens/maxOutputTokens + 上游模型对象全字段)。
// CN /console 与 global /v2 的模型对象同构,故共用此结构;上游省略的字段保持零值,
// /v1/models 侧按「空值省略」透出(不编造)。
type ModelInfo struct {
	ID            string
	Name          string
	ContextWindow int64    // = maxInputTokens
	MaxTokens     int64    // = maxOutputTokens(思考与最终回答共享此预算,上游无独立思考上限字段)
	Efforts       []string // reasoning.supportedEfforts(空=未知/固定档)
	DefaultEffort string   // reasoning.defaultEffort(新模型键)或 reasoning.effort(老模型键);空=未返回

	// 模型目录全字段(models-full-fields):
	Description        string   // descriptionZh 中文描述
	Credits            string   // credits 积分倍率原文(如 "x0.05"),仅展示不参与选号
	Tags               []string // tags 模型标签(含 badge:限时免费 等)
	Vendor             string   // vendor 厂商标识
	IsDefault          bool     // isDefault 是否默认模型
	SupportsReasoning  bool     // supportsReasoning 是否支持推理
	SupportsToolCall   bool     // supportsToolCall 是否支持工具调用
	OnlyReasoning      bool     // onlyReasoning 是否纯推理模型
	SupportsImages     bool     // 顶层 supportsImages(多模态能力,透出到 /v1/models)
	MaxAllowedSize     int64    // maxAllowedSize 最大允许上下文(与 maxInputTokens 口径并列,上游各自下发)
	CanDisableThinking bool     // reasoning.canDisableThinking:思考可关(off 档可用)
	ReasoningEffort    string   // reasoning.effort 推理模式(与 supportedEfforts 数组不同源)
	ReasoningSummary   string   // reasoning.summary 推理摘要模式(如 "auto")

	// 优惠(modelPromotions,/v3/config data.modelPromotions):Credits 是**牌价**
	//(转正后基准倍率),Promo* 是当前生效的限时优惠——面板据此显示「生效价 +
	// 标签 + 牌价」。PromoFactor 为 nil 表示无 machine-readable 折扣(如「错峰
	// 使用」只有时段文案无 factor),仅挂标签/提示。
	PromoFactor  *float64 // 折扣系数(0=限时免费,0.5=五折);nil=无
	PromoCredits string   // 折扣后倍率原文(如 "0x" / "0.50x"),仅展示
	PromoLabel   string   // 徽章文案(限时免费 / 夜间折扣 / 错峰使用)
	PromoNote    string   // hover 说明原文(含时段/日期描述)
}

// dynModelEntry 上游模型目录(CN /console 与 global /v2 同构)的单条模型解析形态,
// FetchModels 与 global_models.go 的探测共用。iconUrl/descriptionEn/生成参数等
// 按「不透出」原则不解析。modelInfo() 是 dynEntry→ModelInfo 映射的单一事实来源,
// 杜绝两域映射漂移。
type dynModelEntry struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	// ModelID / Model id 的宽松回退键(仅 global 目录的多信封兜底用,CN 目录
	// 不下发这两个键;字段加在这里只是让 typed 解析能"看见"它们)。
	ModelID         string   `json:"modelId"`
	Model           string   `json:"model"`
	Description     string   `json:"descriptionZh"`
	Credits         string   `json:"credits"`
	Tags            []string `json:"tags"`
	Vendor          string   `json:"vendor"`
	IsDefault       bool     `json:"isDefault"`
	MaxInputTokens  int64    `json:"maxInputTokens"`
	MaxOutputTokens int64    `json:"maxOutputTokens"`
	MaxAllowedSize  int64    `json:"maxAllowedSize"`
	Disabled        bool     `json:"disabled"`
	SupportsImages  bool     `json:"supportsImages"`
	SupportsReason  bool     `json:"supportsReasoning"`
	SupportsTool    bool     `json:"supportsToolCall"`
	OnlyReasoning   bool     `json:"onlyReasoning"`
	Reasoning       struct {
		Effort             string   `json:"effort"`
		Summary            string   `json:"summary"`
		DefaultEffort      string   `json:"defaultEffort"`
		CanDisableThinking bool     `json:"canDisableThinking"`
		SupportedEfforts   []string `json:"supportedEfforts"`
	} `json:"reasoning"`
}

// modelInfo 按解析条目构造 ModelInfo(dynEntry→ModelInfo 映射的单一事实来源)。
// defaultEffort 新老双键兼容:defaultEffort 优先,缺省回落 effort。
func (m dynModelEntry) modelInfo() ModelInfo {
	def := m.Reasoning.DefaultEffort
	if def == "" {
		def = m.Reasoning.Effort
	}
	return ModelInfo{
		ID:                 m.ID,
		Name:               m.Name,
		ContextWindow:      m.MaxInputTokens,
		MaxTokens:          m.MaxOutputTokens,
		Efforts:            m.Reasoning.SupportedEfforts,
		DefaultEffort:      def,
		SupportsImages:     m.SupportsImages,
		Description:        m.Description,
		Credits:            m.Credits,
		Tags:               m.Tags,
		Vendor:             m.Vendor,
		IsDefault:          m.IsDefault,
		SupportsReasoning:  m.SupportsReason,
		SupportsToolCall:   m.SupportsTool,
		OnlyReasoning:      m.OnlyReasoning,
		MaxAllowedSize:     m.MaxAllowedSize,
		CanDisableThinking: m.Reasoning.CanDisableThinking,
		ReasoningEffort:    m.Reasoning.Effort,
		ReasoningSummary:   m.Reasoning.Summary,
	}
}

// nonChatModel 判定是否非对话模型(应从模型列表过滤掉)。
// 来源:harness buddy.ts:547-555。三类规则:
//   - id 前缀 nes-/completion-/codewise-:嵌入/补全/代码专用模型,选了报 code=11102。
//   - maxOutputTokens ≤ 256:tiny 输出非对话模型。
//   - tags 含生成类标签(图片/视频):生成模型走各自专用端点,作为对话模型
//     选上去只会报 11102,非本网关用途。
//
// 生成类标签随上游扩充:早期只有 text-to-image,桌面端目录(2026-10-02 实测)
// 另有 text-to-video / image-to-video(seedance 系列)与 image-to-image
// (gpt-image 系列)——后者已由 text-to-image 覆盖,此处补齐视频两类。
// 注意本函数 CN 与 global 共用,新增标签对两域同时生效。
func nonChatModel(id string, maxOutputTokens int64, tags []string) bool {
	id = strings.ToLower(strings.TrimSpace(id))
	for _, p := range [...]string{"nes-", "completion-", "codewise-"} {
		if strings.HasPrefix(id, p) {
			return true
		}
	}
	if maxOutputTokens > 0 && maxOutputTokens <= 256 {
		return true
	}
	for _, t := range tags {
		switch t {
		case "text-to-image", "image-to-image", "text-to-video", "image-to-video":
			return true
		}
	}
	return false
}

// codeBuddyIDEUA /v3/config 要求能解析出 CodeBuddy 版本号的 UA。
// CLI 三段式 WorkBuddy UA 会拿到精简目录(flash 输出 128K、无 supportedEfforts);
// 官方 IDE 头 `CodeBuddyIDE/4.12.0 CodeBuddy/4.12.0` 才返回完整能力
// (flash:393216 + low/high/max)。
// 版本号需随上游 IDE 发版跟进:UAn 版本过旧时该端点可能同样返回精简目录。
const codeBuddyIDEUA = "CodeBuddyIDE/4.12.0 CodeBuddy/4.12.0"

// codeBuddyCLIUA CLI 三段式 UA。**实测(2026-09-22)该端点对不同 UA 下发的模型集合不同**:
//   - IDE UA  → 14 条(10 个 chat:含 o4-mini / enhance-1.0 / auto-chat,**无 deepseek 系列**)
//   - CLI UA  → 22 条(22 个 chat:**含 deepseek-v4.1-flash / deepseek-v4.1-flash-sg /
//     gpt-6-astra / kimi-k2.8-preview**,但无 o4-mini / enhance-1.0 / auto-chat)
//
// 注意两点,都与旧注释相反,勿再按旧注释推断:
//  1. 旧注释称「CLI UA 拿到精简目录、IDE UA 才返回完整能力」——实测模型数量恰好相反,
//     但 **IDE 响应体积更大**(26003B vs 21111B),故「完整能力」应理解为**单条字段更全**,
//     而非模型更多。两路各有独有模型,缺一不可。
//  2. 该常量仅用于 global 侧第二路探测;CN 侧仍走 codeBuddyIDEUA 单路。
const codeBuddyCLIUA = "CLI/2.63.2 CodeBuddy/2.63.2"

// FetchModels 调上游动态模型接口(CN 侧;global 账号见 global_models.go 家族)。
//
// v3-config-merge:动态目录 = /v3/config(主,IDE UA 完整能力版)+ 企业端点
// (/console,cli 面过滤,补缺)的并集,两路**并发**探测。合并去重 key = 模型 id,
// v3 条目优先(credits 等字段以 v3 为准),企业端点只补 v3 缺失的模型。
// /v3 失败(400/网络错/解析失败)不拖累企业端点结果——降级为仅企业端点,warn 日志;
// 反之亦然(两路独立容错)。
func (c *Client) FetchModels(a *auth.Auth) ([]ModelInfo, error) {
	type probeResult struct {
		infos []ModelInfo
		err   error
	}
	enterpriseCh := make(chan probeResult, 1)
	v3Ch := make(chan probeResult, 1)
	go func() {
		infos, err := c.fetchEnterpriseModels(a)
		enterpriseCh <- probeResult{infos, err}
	}()
	go func() {
		infos, err := c.fetchV3Models(a)
		v3Ch <- probeResult{infos, err}
	}()
	enterprise := <-enterpriseCh
	v3 := <-v3Ch
	if enterprise.err != nil && v3.err != nil {
		return nil, enterprise.err // 两路全失败:返回企业端点错误(既有调用方语义零漂移)
	}
	if v3.err != nil {
		// /v3 失败降级:不拖累企业端点结果(降级仅企业端点 + warn)。
		log.Printf("WARN: [upstream] fetch models: v3/config probe failed (degraded to enterprise endpoint): %v", v3.err)
	}
	if enterprise.err != nil {
		log.Printf("WARN: [upstream] fetch models: enterprise endpoint failed (v3/config only): %v", enterprise.err)
	}
	out := mergeModelInfos(v3.infos, enterprise.infos)
	if len(out) == 0 {
		return nil, fmt.Errorf("models api returned empty list")
	}
	c.storeModelRates(a.Realm(), out)
	// 刷新 effort 能力缓存(供请求体降级;无 supportedEfforts 的模型不入桶)。
	// 空桶时跳过写:避免「某探测无档位数据」清掉既有桶。
	cache := make(map[string][]string, len(out))
	defCache := make(map[string]string, len(out))
	for _, mi := range out {
		if len(mi.Efforts) > 0 {
			cache[mi.ID] = mi.Efforts
		}
		if mi.DefaultEffort != "" {
			defCache[mi.ID] = mi.DefaultEffort
		}
	}
	if len(cache) == 0 && len(defCache) == 0 {
		return out, nil
	}
	// 按探测账号的 realm 写入对应桶:CN 探测只进 cn 桶,global 同模型名不被污染(C-2)。
	c.storeEfforts(a.Realm(), cache, defCache)
	return out, nil
}

// mergeModelInfos 合并两路模型目录:primary 为主(同 id 以 primary 条目为准——
// credits 等字段以主端点为权威),secondary 只补 primary 缺失的 id。
// 去重 key = 模型 id;输出顺序 = primary 原序在前、secondary 补充项(secondary 原序)
// 在后——稳定输出,不依赖 map 迭代序。
func mergeModelInfos(primary, secondary []ModelInfo) []ModelInfo {
	if len(secondary) == 0 {
		return primary
	}
	seen := make(map[string]bool, len(primary)+len(secondary))
	out := make([]ModelInfo, 0, len(primary)+len(secondary))
	for _, mi := range primary {
		if mi.ID == "" || seen[mi.ID] {
			continue
		}
		seen[mi.ID] = true
		out = append(out, mi)
	}
	for _, mi := range secondary {
		if mi.ID == "" || seen[mi.ID] {
			continue
		}
		seen[mi.ID] = true
		out = append(out, mi)
	}
	return out
}

// fetchEnterpriseModels 单路探测企业模型端点(/console/enterprises/personal/models)。
// 解析口径:agents[cli].models 过滤 + nonChatModel 剔除 + disabled 剔除。
func (c *Client) fetchEnterpriseModels(a *auth.Auth) ([]ModelInfo, error) {
	// 局部变量名避开 url(本包已 import net/url,同名会造成阅读混淆)。
	endpoint := c.chatBase(a) + "/console/enterprises/personal/models"
	req, err := http.NewRequest(http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}
	c.CommonHeaders(req, a) // 复用共享请求头(Origin/Referer/UA/Accept/Content-Type)
	// AccessToken 加锁快照(见 auth.AccessTokenValue:keepalive 刷新在 a.mu 内改写)。
	req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue())
	resp, err := c.HTTP.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	if err != nil {
		// 读失败 → 传输层错误(handler 侧该路径不 NoteError)。
		return nil, fmt.Errorf("read body: %w", err)
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("models api status %d: %s", resp.StatusCode, truncate(string(raw), 120))
	}
	var env struct {
		Code int `json:"code"`
		Data struct {
			Models []dynModelEntry `json:"models"`
			Agents []struct {
				Name   string   `json:"name"`
				Models []string `json:"models"`
			} `json:"agents"`
		} `json:"data"`
	}
	if err := json.Unmarshal(raw, &env); err != nil {
		return nil, fmt.Errorf("models parse: %w", err)
	}
	if env.Code != 0 {
		return nil, fmt.Errorf("models api code=%d", env.Code)
	}
	var cliIDs []string
	for _, ag := range env.Data.Agents {
		if ag.Name == "cli" {
			cliIDs = ag.Models
			break
		}
	}
	if len(cliIDs) == 0 {
		return nil, fmt.Errorf("no cli agent models found")
	}
	// dynMap 收集模型字段;nonChatModel 过滤在写入 dynMap 前执行,
	// 确保非对话条目(nes-/completion-/codewise- 前缀、maxOutputTokens≤256、
	// tags 含 text-to-image)根本不进返回列表(来源:harness buddy.ts:547-555)。
	dynMap := make(map[string]dynModelEntry, len(env.Data.Models))
	for _, m := range env.Data.Models {
		if nonChatModel(m.ID, m.MaxOutputTokens, m.Tags) {
			continue
		}
		dynMap[m.ID] = m
	}
	out := make([]ModelInfo, 0, len(cliIDs))
	for _, id := range cliIDs {
		if m, ok := dynMap[id]; ok && !m.Disabled {
			out = append(out, m.modelInfo())
		}
	}
	if len(out) == 0 {
		return nil, fmt.Errorf("models api returned empty list")
	}
	return out, nil
}

// fetchV3Models 单路探测 /v3/config(IDE UA 完整能力版,见 codeBuddyIDEUA)。
// v3 面取全量 models(不按 agents[cli] 过滤,与 global 探测口径一致),按同一
// nonChatModel 规则剔除非对话条目(selected 会选模型报 code=11102)。
// 失败返回错误(调用方降级为仅企业端点)。
func (c *Client) fetchV3Models(a *auth.Auth) ([]ModelInfo, error) {
	byID, err := c.fetchV3ConfigModelMap(a, codeBuddyIDEUA)
	if err != nil {
		return nil, err
	}
	out := make([]ModelInfo, 0, len(byID))
	for _, mi := range byID {
		if nonChatModel(mi.ID, mi.MaxTokens, mi.Tags) {
			continue
		}
		out = append(out, mi)
	}
	if len(out) == 0 {
		return nil, fmt.Errorf("v3/config returned empty models")
	}
	return out, nil
}

// v3ModelPromotion /v3/config data.modelPromotions 单条优惠定义(2026-09-23 实测
// 7 条:deepseek 系错峰五折、glm-5.2 夜间五折、hy3 与 hy4-preview-f 限时免费)。
// discount 只在部分条目上存在:有 factor 的可算生效价;「错峰使用」类只有时段
// 文案(factor 藏在 hover 文本里,无机器可读值),仅透出标签与说明。
type v3ModelPromotion struct {
	Enabled  bool     `json:"enabled"`
	Priority int      `json:"priority"`
	ModelIDs []string `json:"modelIds"`
	Badge    *struct {
		Label string `json:"label"`
	} `json:"badge"`
	Discount *struct {
		DiscountedCredits string  `json:"discountedCredits"`
		Factor            float64 `json:"factor"`
	} `json:"discount"`
	Hover *struct {
		TextZh string `json:"textZh"`
	} `json:"hover"`
	Schedule *struct {
		Daily []struct {
			Start string `json:"start"` // "23:00"
			End   string `json:"end"`   // "7:50"(可跨午夜)
		} `json:"daily"`
		Timezone   string `json:"timezone"`  // 实测恒 Asia/Shanghai
		ValidFrom  string `json:"validFrom"` // RFC3339,可缺省
		ValidUntil string `json:"validUntil"`
	} `json:"schedule"`
}

// promoZone 优惠时区:上游恒 Asia/Shanghai(UTC+8 无夏令时),用 FixedZone 免依赖
// 系统 tzdata(Windows 无 IANA 库时 LoadLocation 会失败)。
var promoZone = time.FixedZone("CST", 8*3600)

// promoClock 解析 "HH:MM" 为当日分钟数;坏值返回 (-1, false)。
func promoClock(hhmm string) (int, bool) {
	parts := strings.Split(hhmm, ":")
	if len(parts) != 2 {
		return -1, false
	}
	h, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
	m, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
	if err1 != nil || err2 != nil || h < 0 || h > 24 || m < 0 || m > 59 {
		return -1, false
	}
	return h*60 + m, true
}

// promoActive 评估优惠在 now 是否生效:enabled + validFrom/validUntil 内 + 落在
// 任一 daily 窗口(支持跨午夜,如 23:00→7:50)。schedule 为 nil 视为全天生效。
func promoActive(p *v3ModelPromotion, now time.Time) bool {
	if !p.Enabled {
		return false
	}
	if sc := p.Schedule; sc != nil {
		if sc.ValidFrom != "" {
			from, err := time.Parse(time.RFC3339, sc.ValidFrom)
			if err == nil && now.Before(from) {
				return false
			}
		}
		if sc.ValidUntil != "" {
			until, err := time.Parse(time.RFC3339, sc.ValidUntil)
			if err == nil && !now.Before(until) {
				return false
			}
		}
		if len(sc.Daily) > 0 {
			cur := now.Hour()*60 + now.Minute()
			inWindow := false
			for _, w := range sc.Daily {
				st, ok1 := promoClock(w.Start)
				ed, ok2 := promoClock(w.End)
				if !ok1 || !ok2 {
					continue
				}
				if st <= ed {
					if cur >= st && cur < ed {
						inWindow = true
						break
					}
				} else if cur >= st || cur < ed { // 跨午夜(23:00→7:50)
					inWindow = true
					break
				}
			}
			if !inWindow {
				return false
			}
		}
	}
	return true
}

// applyModelPromotions 把当前生效的优惠挂到目录条目:同模型多条命中取 priority
// 最高(实测 glm-5.2 白天 badge-only(50) 与夜间五折(100) 靠 priority+daily 双轨
// 切换)。无 discount 对象的条目也挂标签/说明(错峰类),PromoFactor 留 nil。
func applyModelPromotions(out map[string]ModelInfo, promos []v3ModelPromotion) {
	if len(promos) == 0 || len(out) == 0 {
		return
	}
	now := time.Now().In(promoZone)
	type cand struct {
		prio int
		p    *v3ModelPromotion
	}
	best := map[string]cand{}
	for i := range promos {
		p := &promos[i]
		if !promoActive(p, now) {
			continue
		}
		for _, id := range p.ModelIDs {
			if _, ok := out[id]; !ok {
				continue // 目录外模型(如同名 global 变体)不挂
			}
			if b, seen := best[id]; !seen || p.Priority > b.prio {
				best[id] = cand{prio: p.Priority, p: p}
			}
		}
	}
	for id, c := range best {
		mi := out[id]
		if c.p.Badge != nil {
			mi.PromoLabel = c.p.Badge.Label
		}
		if c.p.Hover != nil {
			mi.PromoNote = c.p.Hover.TextZh
		}
		if c.p.Discount != nil {
			f := c.p.Discount.Factor
			mi.PromoFactor = &f
			mi.PromoCredits = c.p.Discount.DiscountedCredits
		}
		out[id] = mi
	}
}

// storeEfforts 按 realm 写入 effort 能力缓存桶(efforts + defaultEfforts),并发安全。
// 供 CN FetchModels 与 global 探测共用:拉取到的模型档位落桶后,出站请求体
// normalizeReasoningEffort 才能按域降级。efforts 与 defs 均空时删除该 realm 桶
// (等价「该域无可降级档位」)。调用方负责在「无新数据」时跳过写。
func (c *Client) storeEfforts(realm string, efforts map[string][]string, defs map[string]string) {
	c.effortsMu.Lock()
	defer c.effortsMu.Unlock()
	if c.efforts == nil {
		c.efforts = make(map[string]map[string][]string)
	}
	if c.defaultEfforts == nil {
		c.defaultEfforts = make(map[string]map[string]string)
	}
	k := realmKey(realm)
	if len(efforts) == 0 && len(defs) == 0 {
		delete(c.efforts, k)
		delete(c.defaultEfforts, k)
		return
	}
	c.efforts[k] = efforts
	c.defaultEfforts[k] = defs
}

// normalizeModelRate 把上游倍率原文规范化为可比较的数值键。
// 兼容 "x0.05" / "x0.05 credits" / "0.50x" 等形态;无法数值化时保留去除
// credits 后缀与空白后的原文,避免编造倍率。
func normalizeModelRate(raw string) string {
	s := strings.TrimSpace(raw)
	if s == "" {
		return ""
	}
	if strings.HasSuffix(strings.ToLower(s), "credits") {
		s = strings.TrimSpace(s[:len(s)-len("credits")])
	}
	if strings.HasPrefix(strings.ToLower(s), "x") {
		s = strings.TrimSpace(s[1:])
	} else if strings.HasSuffix(strings.ToLower(s), "x") {
		s = strings.TrimSpace(s[:len(s)-1])
	}
	if s == "" {
		return ""
	}
	v, err := strconv.ParseFloat(s, 64)
	if err != nil {
		return strings.TrimSpace(raw)
	}
	return strconv.FormatFloat(v, 'f', -1, 64)
}

// effectiveModelRate 返回模型当前生效倍率:有机器可读优惠时取折扣价,
// 否则取牌价;两者均缺省时为空。
func effectiveModelRate(mi ModelInfo) string {
	if mi.PromoFactor != nil && strings.TrimSpace(mi.PromoCredits) != "" {
		return normalizeModelRate(mi.PromoCredits)
	}
	return normalizeModelRate(mi.Credits)
}

// storeModelRates 按 realm 整体替换模型倍率快照。目录成功刷新但没有可解析
// 倍率时写入空桶,使旧倍率不会继续冒充当前价。
func (c *Client) storeModelRates(realm string, infos []ModelInfo) {
	rates := make(map[string]string, len(infos))
	for _, mi := range infos {
		if mi.ID == "" {
			continue
		}
		if rate := effectiveModelRate(mi); rate != "" {
			rates[mi.ID] = rate
		}
	}
	c.effortsMu.Lock()
	defer c.effortsMu.Unlock()
	if c.modelRates == nil {
		c.modelRates = make(map[string]map[string]string)
	}
	c.modelRates[realmKey(realm)] = rates
}

// ModelRate 返回最近成功刷新的指定域模型生效倍率;未知返回空串。
func (c *Client) ModelRate(realm, model string) string {
	if c == nil || model == "" {
		return ""
	}
	c.effortsMu.RLock()
	defer c.effortsMu.RUnlock()
	return c.modelRates[realmKey(realm)][model]
}

// GlobalEffortSnapshot 导出 global 域 effort 能力缓存(探测下发 ∪ 静态兜底合并后的桶),
// 供 /v1/models 输出 reasoning_supported_efforts / reasoning_default_effort。
// 返回副本;桶未填充(无 global 账号或从未探测)→ nil(调用方回落静态兜底表)。
func (c *Client) GlobalEffortSnapshot() (efforts map[string][]string, defaults map[string]string) {
	return c.effortsSnapshot("global"), c.defaultEffortsSnapshot("global")
}

// v3ConfigDomain /v3/config 的 X-Domain:优先账号落盘 domain,否则 chatBase host。
func v3ConfigDomain(a *auth.Auth, chatBase string) string {
	if a != nil {
		// Domain 加锁快照(见 auth.DomainValue:keepalive 刷新在 a.mu 内改写)。
		if d := strings.TrimSpace(a.DomainValue()); d != "" {
			d = strings.TrimPrefix(d, "https://")
			d = strings.TrimPrefix(d, "http://")
			return strings.TrimSuffix(d, "/")
		}
	}
	if u, err := url.Parse(chatBase); err == nil && u.Host != "" {
		return u.Host
	}
	return "copilot.tencent.com"
}

// fetchV3ConfigModelMap 拉官方 IDE 配置目录,按模型 id 建能力表。
// 该端点对 UA 敏感:必须带 CodeBuddy/CodeBuddyIDE 版本,否则 400 code=12403。
// ua 为该次请求的 User-Agent;空串等价 codeBuddyIDEUA。该端点对 UA 敏感且**不同 UA 下发
// 不同模型集合**(见 codeBuddyCLIUA 注释),global 探测据此并发两路取并集。
func (c *Client) fetchV3ConfigModelMap(a *auth.Auth, ua string) (map[string]ModelInfo, error) {
	req, err := http.NewRequest(http.MethodGet, c.chatBase(a)+"/v3/config", nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Accept", "application/json, text/plain, */*")
	req.Header.Set("X-Requested-With", "XMLHttpRequest")
	// AccessToken 加锁快照(同 fetchEnterpriseModels)。
	req.Header.Set("Authorization", "Bearer "+a.AccessTokenValue())
	if a != nil && a.UID != "" {
		req.Header.Set("X-User-Id", a.UID)
	}
	req.Header.Set("X-Domain", v3ConfigDomain(a, c.chatBase(a)))
	req.Header.Set("X-Product", "SaaS")
	if ua == "" {
		ua = codeBuddyIDEUA
	}
	req.Header.Set("User-Agent", ua)
	c.injectCodeBuddyRequest(req)
	resp, err := c.HTTP.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	raw, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
	if err != nil {
		// 读失败 → 传输层错误:半截 body 不进解析(不罚号)。
		return nil, fmt.Errorf("read body: %w", err)
	}
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("v3/config status %d: %s", resp.StatusCode, truncate(string(raw), 120))
	}
	var env struct {
		Code int `json:"code"`
		Data struct {
			Models []dynModelEntry `json:"models"`
			// 试用模型横幅:上游把「N 天免费试用」的模型放在这里,**不在 data.models 里**。
			// 实测 global 侧 hy4-preview-f 只出现在此(modelId=hy4-preview-f、
			// targetModelId=hy4-preview、trialDays=14),纯 data.models 解析会漏掉它。
			ProductFeaturesConfig struct {
				ModelTrialBanner struct {
					Banners []struct {
						ModelID       string `json:"modelId"`
						TargetModelID string `json:"targetModelId"`
					} `json:"banners"`
				} `json:"ModelTrialBanner"`
			} `json:"productFeaturesConfig"`
			ModelPromotions []v3ModelPromotion `json:"modelPromotions"`
		} `json:"data"`
	}
	if err := json.Unmarshal(raw, &env); err != nil {
		return nil, fmt.Errorf("v3/config parse: %w", err)
	}
	if env.Code != 0 {
		return nil, fmt.Errorf("v3/config code=%d", env.Code)
	}
	out := make(map[string]ModelInfo, len(env.Data.Models))
	for _, m := range env.Data.Models {
		if strings.TrimSpace(m.ID) == "" {
			continue
		}
		out[m.ID] = m.modelInfo()
	}
	// 补入试用横幅模型(ModelTrialBanner):上游把「N 天免费试用」的模型只放在这里,
	// data.models 里没有,故纯目录解析会漏(实测 global 侧 hy4-preview-f 即如此,
	// 但该模型**实际可调用**)。
	//
	// 元数据口径:能力字段(context/maxTokens/efforts/reasoning 等)从 targetModelId
	// 的既有条目继承——试用版与其转正目标是同族模型,能力应当一致;
	// 但 **Credits 与 Tags 显式清空**——它们描述的是"转正后"的计费与营销信息
	// (如 hy4-preview 的 x0.29 与 badge),用在免费试用版上会误导下游展示。
	//
	// firstUseTimeKey / trialDays 属**账号级**试用状态,不透出给下游。
	for _, b := range env.Data.ProductFeaturesConfig.ModelTrialBanner.Banners {
		id := strings.TrimSpace(b.ModelID)
		if id == "" {
			continue
		}
		if _, exists := out[id]; exists {
			continue
		}
		mi := ModelInfo{ID: id}
		if tgt := strings.TrimSpace(b.TargetModelID); tgt != "" {
			if base, ok := out[tgt]; ok {
				mi = base
				mi.ID = id
			}
		}
		mi.Credits = ""
		mi.Tags = nil
		out[id] = mi
	}
	// 挂当前生效的限时优惠(modelPromotions):Credits 字段是**牌价**(转正后基准
	// 倍率,如 hy4-preview-f 的 x0.29),而 WorkBuddy 客户端显示的是生效价(试用/
	// 折扣窗口内 factor 打折)——面板据此展示「生效价 + 标签 + 牌价」。
	applyModelPromotions(out, env.Data.ModelPromotions)

	if len(out) == 0 {
		return nil, fmt.Errorf("v3/config returned empty models")
	}
	return out, nil
}

// UserResource 查询账号积分余额与总额度(所有套餐聚合)。remain 负值钳 0;
// total 取与 remain 同源的额度字段(CycleCapacitySize 优先,无周期额度退
// CapacitySize),上游缺 size 的套餐按 remain 兜底,保证百分比不超 100%。
// CreditPackage 单个积分包的构成明细(面板「积分构成」用)。
//
// 两个账号即使任务完成度完全一致,余额也可能相差上千——差别藏在包的**面额与
// 来源**里(「国内运营裂变包」「拉新权益包」按次发放,面额 6~1500 不等)。
// 只看聚合值看不出这件事,所以把逐包明细暴露出来。
type CreditPackage struct {
	Name   string `json:"name"`
	Remain int64  `json:"remain"`
	Used   int64  `json:"used"`
	Size   int64  `json:"size"`
	// EndTime 该包的失效时刻:优先 DeductionEndTime(可抵扣窗口结束,真「用不完
	// 就没了」),缺失依次回落 ExpiredTime / PackageEndTime / CycleEndTime(周期
	// 边界,仅兜底)。RFC3339 或上游墙钟字符串,前端取日期部分展示。
	EndTime string `json:"end_time,omitempty"`
	// ExpiresAt 与 EndTime 同源的 Unix 毫秒时间戳,供面板按精确剩余天数聚合。
	ExpiresAt int64 `json:"expires_at,omitempty"`
	// CreatedAt 发放时刻,RFC3339。**这是区分「首登赠送」与「活动奖励」的唯一依据**:
	// 两类包的 PackageName 与 PackageCode 完全相同(例如都是「国内运营裂变包」+
	// TCACA_code_007_*),只看名字无法区分,只有时间能说明它是不是账号首次授权那刻发的。
	CreatedAt string `json:"created_at,omitempty"`
	// PackageCode / SubProductCode 上游的包类型标识。同 Name 不同 Code 的包可能
	// 是不同来源;同 Code 不同面额则是同来源分批发放(首登 1500 与活动 300 即如此)。
	PackageCode    string `json:"package_code,omitempty"`
	SubProductCode string `json:"sub_product_code,omitempty"`
	SubProductName string `json:"sub_product_name,omitempty"`
	// Cycle 为 true 表示按周期发放的包(读 Cycle* 字段),否则读 Capacity*。
	Cycle bool `json:"cycle,omitempty"`
}

// CreditPackages 返回账号当前的逐包构成。remain/size 为各包求和。
//
// 字段选择与 UserResourceDetailed 的聚合口径一致:CycleCapacitySize > 0 时按
// 周期字段算,否则按 Capacity 字段算——两条路径不能混,否则同一个包会被算两次。
func (c *Client) CreditPackages(a *auth.Auth) ([]CreditPackage, int64, int64, error) {
	now := time.Now()
	body := map[string]any{
		"PageNumber":               1,
		"PageSize":                 100,
		"ProductCode":              "p_tcaca",
		"Status":                   []int{0, 3},
		"PackageEndTimeRangeBegin": now.Format(packageEndLayout),
		"PackageEndTimeRangeEnd":   now.Add(365 * 101 * 24 * time.Hour).Format(packageEndLayout),
	}
	data, err := c.billingMeterJSON(a, c.billingMeterPaths(a), http.MethodPost, body)
	if err != nil {
		return nil, 0, 0, err
	}
	// 注意层级:doJSON 已经解过 apiEnvelope 并返回 env.Data,所以这里从
	// Response 开始解析——**不能**再套一层 Code/Data,否则 Accounts 恒为空,
	// 表现为「每个号都 0 个包」(实测踩过)。
	var resp struct {
		Response struct {
			Data struct {
				Accounts []struct {
					PackageName         string `json:"PackageName"`
					CapacityRemain      int64  `json:"CapacityRemain"`
					CapacityUsed        int64  `json:"CapacityUsed"`
					CapacitySize        int64  `json:"CapacitySize"`
					CycleCapacityRemain int64  `json:"CycleCapacityRemain"`
					CycleCapacityUsed   int64  `json:"CycleCapacityUsed"`
					CycleCapacitySize   int64  `json:"CycleCapacitySize"`
					// 到期时间字段名在上游存在三种口径:ExpiredTime / PackageEndTime
					// 在 CN/global 实测字段全集里均恒 miss(见 UserResourceDetailed
					// 处注释),真实下发的是 CycleEndTime——三者都读,谁有值用谁。
					ExpiredTime    string `json:"ExpiredTime"`
					PackageEndTime string `json:"PackageEndTime"`
					CycleEndTime   string `json:"CycleEndTime"`
					// DeductionEndTime 可抵扣窗口结束(epoch 毫秒)——「这个包什么时候
					// 不能再花」的真失效时刻。CycleEndTime 是周期边界(额度重置点),
					// 两者语义不同:判「用不完就没了」以本字段为准,CycleEndTime 兜底
					//(OkRoromori 分支实测结论:请求参数叫 PackageEndTimeRange*,但
					// 响应里 ExpiredTime 恒空,真正的失效时刻只有这里下发)。
					DeductionEndTime int64 `json:"DeductionEndTime"`
					// 发放时刻(epoch 毫秒)。
					CreateTime     int64  `json:"CreateTime"`
					PackageCode    string `json:"PackageCode"`
					SubProductCode string `json:"SubProductCode"`
					SubProductName string `json:"SubProductName"`
				} `json:"Accounts"`
			} `json:"Data"`
		} `json:"Response"`
	}
	if err := json.Unmarshal(data, &resp); err != nil {
		return nil, 0, 0, fmt.Errorf("packages parse: %w", err)
	}
	packs := resp.Response.Data.Accounts
	out := make([]CreditPackage, 0, len(packs))
	var sumRemain, sumSize int64
	for _, p := range packs {
		cp := CreditPackage{
			Name:           p.PackageName,
			PackageCode:    p.PackageCode,
			SubProductCode: p.SubProductCode,
			SubProductName: p.SubProductName,
		}
		switch {
		case p.DeductionEndTime > 0:
			// 真失效时刻(可抵扣窗口结束),语义见上方字段注释:判「用不完就没了」
			// 用它而不是周期边界。epoch 毫秒 → RFC3339,与 CycleEndTime 字符串口径
			// 共存(前端统一 slice(0,10) 取日期)。ExpiresAt 直接用原始毫秒——
			// RFC3339 不是 packageEndLayout 形态,交给下方解析会静默失败得 0。
			cp.EndTime = time.UnixMilli(p.DeductionEndTime).Format(time.RFC3339)
			cp.ExpiresAt = p.DeductionEndTime
		case p.ExpiredTime != "":
			cp.EndTime = p.ExpiredTime
		case p.PackageEndTime != "":
			cp.EndTime = p.PackageEndTime
		default:
			cp.EndTime = p.CycleEndTime
		}
		if cp.ExpiresAt == 0 && cp.EndTime != "" {
			if end, perr := time.ParseInLocation(packageEndLayout, cp.EndTime, softRateResetLoc); perr == nil {
				cp.ExpiresAt = end.UnixMilli()
			}
		}
		// CreateTime 是 epoch 毫秒;0 表示上游没给,留空而不是伪造 1970。
		if p.CreateTime > 0 {
			cp.CreatedAt = time.UnixMilli(p.CreateTime).Format(time.RFC3339)
		}
		if p.CycleCapacitySize > 0 {
			cp.Cycle = true
			cp.Remain, cp.Size = p.CycleCapacityRemain, p.CycleCapacitySize
			cp.Used = cp.Size - cp.Remain
			if p.CycleCapacityUsed > cp.Used {
				cp.Used = p.CycleCapacityUsed
				cp.Remain = cp.Size - cp.Used
			}
			if cp.Remain < 0 {
				cp.Remain = 0
			}
		} else {
			cp.Remain, cp.Used, cp.Size = p.CapacityRemain, p.CapacityUsed, p.CapacitySize
			if cp.Used == 0 && cp.Size > cp.Remain {
				cp.Used = cp.Size - cp.Remain
			}
		}
		sumRemain += cp.Remain
		sumSize += cp.Size
		out = append(out, cp)
	}
	// 面额降序:大包一眼可见,正是差异最可能出现的地方。
	sort.SliceStable(out, func(i, j int) bool { return out[i].Size > out[j].Size })
	return out, sumRemain, sumSize, nil
}

func (c *Client) UserResource(a *auth.Auth) (remain, total int64, err error) {
	remain, total, _, err = c.UserResourceDetailed(a, 0)
	return remain, total, err
}

// packageEndLayout 上游套餐到期时间的墙钟格式(UTC+8,与 softRateResetLoc 同口径)。
const packageEndLayout = "2006-01-02 15:04:05"

// parsePackageEndTime 统一解析上游套餐到期时间。空值、格式异常返回 false,
// 调用方据此保守地不把该包计入最早到期路由。
func parsePackageEndTime(raw string) (time.Time, bool) {
	if raw == "" {
		return time.Time{}, false
	}
	t, err := time.ParseInLocation(packageEndLayout, raw, softRateResetLoc)
	if err != nil {
		return time.Time{}, false
	}
	return t, true
}

// UserResourceDetailed 在 UserResource 基础上额外返回「快过期」积分子集:
// soon > 0 且套餐 CycleEndTime 解析成功且到期时刻 ≤ now+soon 的余额计入 expiring
// (pool 据此优先消耗,避免官方活动赠送的奖励积分到期作废);soon ≤ 0 时 expiring
// 恒 0(禁用分桶,行为与引入前一致)。expiring 是 remain 的一部分。
//
// 到期时间判据是 CycleEndTime(上游实测:CN/global 两域字段全集均无 PackageEndTime,
// 旧判据恒 miss 致 expiring 恒 0;CycleEndTime 是上游真实下发的到期时刻——
// global Bonus Pack 14 天赠送积分的到期时间即此字段)。解析失败/缺失的套餐保守
// 不计入 expiring(不误标为快过期而插队)。
// 单套餐取数统一调 packageRemainUsed(与 CreditPackages 同一事实来源,含 remain
// 钳 [0,size] 与 used 修正;消除双份逻辑漂移——旧中间 switch 只钳负值,上游脏数据
// CycleRemain>Size 时会高估)。
func (c *Client) UserResourceDetailed(a *auth.Auth, soon time.Duration) (remain, total, expiring int64, err error) {
	remain, total, expiring, _, _, err = c.UserResourceDetailedWithExpiry(a, soon)
	return remain, total, expiring, err
}

// UserResourceDetailedWithExpiry 在 UserResourceDetailed 基础上返回最早未来到期批次:
// earliestAt 是最早的可用到期时刻,earliestRemaining 是同一时刻所有正余额包的剩余量之和。
// 已过期、剩余为 0、缺少或无法解析到期时间的包都不会成为最早批次;无有效批次时返回零值。
func (c *Client) UserResourceDetailedWithExpiry(a *auth.Auth, soon time.Duration) (remain, total, expiring int64, earliestAt time.Time, earliestRemaining int64, err error) {
	now := time.Now()
	body := map[string]any{
		"PageNumber":               1,
		"PageSize":                 100,
		"ProductCode":              "p_tcaca",
		"Status":                   []int{0, 3},
		"PackageEndTimeRangeBegin": now.Format(packageEndLayout),
		"PackageEndTimeRangeEnd":   now.Add(365 * 101 * 24 * time.Hour).Format(packageEndLayout),
	}
	// 余额查询同样做瞬时错误有界重试(签到后紧接着的 user-resource 偶发 500 会让
	// 该账号错过本次解冻/到期快照更新,只能等下一个刷新周期)。
	var data json.RawMessage
	err = c.retryBillingTransient(func() error {
		var e error
		data, e = c.billingMeterJSON(a, c.billingMeterPaths(a), http.MethodPost, body)
		return e
	})
	if err != nil {
		return 0, 0, 0, time.Time{}, 0, err
	}
	var resp struct {
		Response struct {
			Data struct {
				Accounts []struct {
					PackageName         string `json:"PackageName"`
					CycleEndTime        string `json:"CycleEndTime"` // "2006-01-02 15:04:05",缺省/空 = 无到期
					CapacitySize        int64  `json:"CapacitySize"`
					CapacityRemain      int64  `json:"CapacityRemain"`
					CapacityUsed        int64  `json:"CapacityUsed"`
					CycleCapacitySize   int64  `json:"CycleCapacitySize"`
					CycleCapacityRemain int64  `json:"CycleCapacityRemain"`
					CycleCapacityUsed   int64  `json:"CycleCapacityUsed"`
				} `json:"Accounts"`
			} `json:"Data"`
		} `json:"Response"`
	}
	if err := json.Unmarshal(data, &resp); err != nil {
		return 0, 0, 0, time.Time{}, 0, fmt.Errorf("resource parse: %w", err)
	}
	for _, acct := range resp.Response.Data.Accounts {
		r, _, size := packageRemainUsed(respAccount{
			CapacityRemain:      acct.CapacityRemain,
			CapacityUsed:        acct.CapacityUsed,
			CapacitySize:        acct.CapacitySize,
			CycleCapacityRemain: acct.CycleCapacityRemain,
			CycleCapacityUsed:   acct.CycleCapacityUsed,
			CycleCapacitySize:   acct.CycleCapacitySize,
		})
		if r < 0 {
			r = 0
		}
		if size < r {
			size = r
		}
		remain += r
		total += size
		if r <= 0 {
			continue
		}
		end, ok := parsePackageEndTime(acct.CycleEndTime)
		if !ok || !end.After(now) {
			continue
		}
		if earliestAt.IsZero() || end.Before(earliestAt) {
			earliestAt = end
			earliestRemaining = r
		} else if end.Equal(earliestAt) {
			earliestRemaining += r
		}
		// 分桶:仅 soon>0 且确实在窗口内 → expiring。
		if soon > 0 && !end.After(now.Add(soon)) {
			expiring += r
		}
	}
	return remain, total, expiring, earliestAt, earliestRemaining, nil
}

// respAccount 供 packageRemainUsed 解析的套餐字段(CreditPackages 的逐包结构同构)。
type respAccount struct {
	CapacityRemain      int64
	CapacityUsed        int64
	CapacitySize        int64
	CycleCapacityRemain int64
	CycleCapacityUsed   int64
	CycleCapacitySize   int64
}

// packageRemainUsed 聚合单套餐的 remain/used/size(与 CreditPackages/cmd/credit 的
// 历史口径一致,收敛至此作为单一事实来源)。Cycle 期套餐优先:用 CycleCapacity
// 三字段,used 取 CycleUsed 与 size-remain 的较大者;否则回退 Capacity 三字段。
func packageRemainUsed(a respAccount) (remain, used, size int64) {
	if a.CycleCapacitySize > 0 {
		remain = a.CycleCapacityRemain
		size = a.CycleCapacitySize
		if remain < 0 {
			remain = 0
		}
		if remain > size {
			remain = size
		}
		used = size - remain
		if a.CycleCapacityUsed > used {
			used = a.CycleCapacityUsed
			if size >= used {
				remain = size - used
			}
		}
		return remain, used, size
	}
	remain = a.CapacityRemain
	used = a.CapacityUsed
	size = a.CapacitySize
	if used == 0 && size > remain {
		used = size - remain
	}
	return remain, used, size
}

// DailyCheckin 执行每日签到。已签到(业务 code 非 0)也返回错误,调用方按 msg 区分。
// 偶发上游 5xx(code 10000)做有界重试(见 retryBillingTransient)——单次抖动不再
// 让该账号整天漏签;「已签到」等业务错误不重试。
func (c *Client) DailyCheckin(a *auth.Auth) error {
	return c.retryBillingTransient(func() error {
		_, err := c.billingMeterJSON(a, c.checkinMeterPaths(a), http.MethodPost, map[string]any{})
		return err
	})
}

// IsAlreadyCheckin 报告 err 是否表示"今天已签到"(上游幂等拒绝重复签到)。
// 只认带分类的 *Error(业务 code 或 HTTP 错误):网络层/解析层错误不得当作幂等成功,
// 否则停机补签遇到抖动会误记为 already,账号当天实际未签到却被判定正常。
func IsAlreadyCheckin(err error) bool {
	var ue *Error
	if !errors.As(err, &ue) {
		return false
	}
	for _, m := range alreadyCheckinMarkers {
		if strings.Contains(ue.Msg, m) || strings.Contains(strings.ToLower(ue.Msg), strings.ToLower(m)) {
			return true
		}
	}
	return false
}

func truncate(s string, n int) string {
	return logfmt.Truncate(s, n)
}