File size: 65,609 Bytes
f4541b6
 
 
8088875
 
 
 
 
 
 
 
 
 
 
 
f4541b6
0231737
 
 
 
 
 
f4541b6
 
 
 
0231737
 
 
 
f4541b6
8088875
f4541b6
 
 
 
 
 
2d8e7b4
8088875
 
f4541b6
8088875
f4541b6
8088875
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
46a8483
f4541b6
 
 
 
 
 
 
0231737
 
 
 
 
f4541b6
085d867
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d8e7b4
 
 
 
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8088875
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d8e7b4
f4541b6
 
 
46a8483
8088875
 
 
 
 
f4541b6
 
 
 
 
 
8088875
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4541b6
8088875
 
 
 
f4541b6
8088875
 
f4541b6
8088875
 
 
 
 
 
 
 
 
f4541b6
8088875
f4541b6
 
 
 
 
 
 
 
 
 
 
2d8e7b4
f4541b6
 
 
2d8e7b4
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0231737
27699df
 
f4541b6
46a8483
8088875
 
f4541b6
 
 
 
 
 
 
802422f
 
2d8e7b4
 
 
 
 
 
 
 
f4541b6
 
 
 
 
a49e358
 
 
 
 
 
 
 
 
 
 
 
 
 
f4541b6
 
 
2d8e7b4
 
 
 
 
 
 
f4541b6
2d8e7b4
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0231737
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8088875
 
 
 
 
 
 
f4541b6
 
 
 
 
 
 
 
 
 
 
8088875
 
f4541b6
 
 
 
 
2d8e7b4
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
0231737
f4541b6
 
 
 
 
 
 
 
2d8e7b4
 
802422f
f4541b6
 
 
2d8e7b4
 
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
802422f
 
 
 
 
 
 
 
 
46a8483
802422f
2d8e7b4
 
 
802422f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d8e7b4
802422f
 
 
 
 
 
 
 
2d8e7b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
802422f
 
 
 
 
 
 
2d8e7b4
 
 
 
802422f
 
f4541b6
2d8e7b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4541b6
 
 
 
 
 
 
 
46a8483
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8088875
 
46a8483
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8088875
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4541b6
 
 
 
 
 
 
2d8e7b4
 
 
 
 
 
 
 
 
 
 
 
 
 
f4541b6
 
 
 
2d8e7b4
 
f4541b6
 
 
 
 
 
 
 
 
 
2d8e7b4
 
 
 
 
 
 
 
 
f4541b6
 
 
 
 
 
 
2d8e7b4
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
2d8e7b4
 
0231737
 
 
 
 
2d8e7b4
f4541b6
 
 
 
 
 
 
 
2d8e7b4
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d8e7b4
 
 
 
f4541b6
 
2d8e7b4
 
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a49e358
 
 
 
f4541b6
 
 
 
 
 
 
 
2d8e7b4
f4541b6
 
 
 
 
 
 
 
 
0231737
 
f4541b6
 
46a8483
8088875
f4541b6
 
 
0231737
 
 
 
 
 
 
 
 
 
 
 
 
a49e358
 
0231737
 
f4541b6
 
3ab7d3d
46a8483
3ab7d3d
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2d8e7b4
 
 
 
f4541b6
46a8483
 
2d8e7b4
f4541b6
 
 
46a8483
 
 
2d8e7b4
 
 
 
 
 
 
 
 
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0231737
 
 
 
 
f4541b6
0231737
 
 
f4541b6
0231737
f4541b6
 
 
 
0231737
f4541b6
0231737
f4541b6
0231737
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a49e358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f4541b6
a49e358
f4541b6
 
a49e358
 
 
 
f4541b6
 
 
a49e358
f4541b6
 
 
 
 
 
 
c8230e5
f4541b6
c8230e5
 
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
c8230e5
 
 
 
 
 
 
 
 
 
 
 
 
f4541b6
 
 
 
 
a49e358
f4541b6
 
 
c8230e5
 
f4541b6
 
c8230e5
 
 
 
 
 
 
 
f4541b6
 
 
 
c8230e5
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0231737
 
f4541b6
0231737
 
 
 
 
f4541b6
0231737
 
 
 
 
 
f4541b6
 
 
 
3ab7d3d
 
 
 
46a8483
 
 
 
 
 
3ab7d3d
f4541b6
 
 
 
46a8483
3ab7d3d
f4541b6
 
8a9f39d
 
 
 
a49e358
 
 
 
 
f4541b6
0231737
085d867
8a9f39d
0231737
f4541b6
 
7e2c6c8
35222b8
 
 
 
 
7e2c6c8
 
35222b8
 
f4541b6
 
 
 
 
085d867
 
 
 
 
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0231737
f4541b6
 
 
 
0231737
f4541b6
 
 
 
 
 
 
 
 
0231737
 
f4541b6
 
46a8483
8088875
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
8088875
 
 
 
fbf85cb
f4541b6
 
 
 
 
 
 
 
0231737
 
f4541b6
 
0231737
 
 
 
 
 
f4541b6
 
 
 
 
 
 
46a8483
 
 
 
 
 
 
8088875
 
 
 
 
 
 
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0231737
f4541b6
 
 
 
 
 
 
 
0231737
f4541b6
 
0231737
f4541b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
server.py — 网页通道 → OpenAI 兼容中转站 (v4: 工厂页三模式 + 请求级重试)

v4 变更(经 hf_reco/poc_mock_factory.py 三臂对照实验验证):
  token 工厂页不再必须真实加载目标站。签发只依赖三件事:
    ① 目标站 origin(Turnstile sitekey 域名校验)  ② 真实浏览器引擎
    ③ challenges.cloudflare.com 可达
  "页面"只是容器, 因此提供三种承载方式(FACTORY_MODE, 面板可热切):
    intercept(默认/推荐): Playwright route 拦截, 空壳页纯内存承载,
      零基础设施(无证书/无端口/无 DNS 改动), 签发与目标站页面完全解耦
    mock: --host-resolver-rules 域名映射 + 本地 HTTPS 空壳页(自签证书),
      不经 CDP 拦截层(intercept 被针对时的 Plan B)
    real: 真实加载工厂页(旧行为, sitekey 轮换等场景的保底)

设计原则(2026-09-27 定稿):
  中继不判断、不记忆、不替上游做主 —— 所有机制只朝"让这个请求成功"使劲:
    - 遇到上游错误就换新 token 重试(状态码集合与次数可配, 默认 5 次)
    - token 货架预热(供给的"缓存"), 请求零等待
    - 模型列表 = 原样拉上游, 不探测不筛选
  没有任何基于历史记录的拦截(探测/否定缓存已移除), 失败都是上游当下的真实回答。

特性:
  - Token 预热货架: 常备现货, 请求零等待签发
  - 多工人页并行签发, 运行时可扩缩容 (POST /api/pool/scale)
  - 全格式透传: /v1/chat/completions(流式/非流式边缘聚合, 含 tool_calls) + /v1/completions + /v1/embeddings
  - 请求级重试: 上游错误换新 token 重试(max_retry / retry_statuses 可热改), 仍失败则错误字节级透传
  - /v1/models: 直接拉上游全量列表(60s 微缓存, 只为快不为筛选)
  - Web 管理面板: 实时池状态/签发统计/工人页明细/最近请求/测试对话

依赖: pip install fastapi uvicorn httpx playwright cryptography
运行: uvicorn server:app --host 0.0.0.0 --port 8377
面板: http://127.0.0.1:8377/  (用任一 relay key 登录)
"""
import asyncio
import collections
import json
import math
import ssl
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse

import httpx
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse,
                               Response, StreamingResponse)
from playwright.async_api import async_playwright

from config import cfg                         # 配置统一来自环境变量

# ----------------------------- 配置(来自 config.py / 环境变量) -----------------------------
UPSTREAM         = cfg.UPSTREAM                # 静态: 换上游需重启
SITEKEY          = cfg.SITEKEY                  # 静态: Cloudflare sitekey
DEFAULT_FACTORY_PAGE = cfg.FACTORY_PAGE         # 工厂页初值(运行时可在面板热切换)
UA               = cfg.UA

DEFAULT_WORKER_PAGES     = cfg.WORKER_PAGES                 # 动态初值(面板可热改)
DEFAULT_MAX_WORKER_PAGES = cfg.MAX_WORKER_PAGES
DEFAULT_SHELF_SIZE       = cfg.SHELF_SIZE
DEFAULT_TOKEN_MAX_AGE    = cfg.TOKEN_MAX_AGE
DEFAULT_IDLE_PAUSE       = cfg.IDLE_PAUSE
DEFAULT_MAX_RETRY        = cfg.MAX_RETRY                    # 上游错误重试次数(0=不重试)
DEFAULT_RETRY_STATUSES   = cfg.RETRY_STATUSES               # 触发重试的上游状态码集合
# /v1/models 微缓存(只为快, 不为筛选)
MODELS_CACHE_TTL = 60
_models_cache: dict = {}     # {"key": path, "ts": monotonic, "status": int, "body": bytes}


def _load_saturation() -> float:
    """标准自报块: 饱和度(0..1) = 忙工人页/总工人页。渠道自定义语义, 平台只看数。"""
    fs = list(pool.factories.values())
    if not fs:
        return 0.0
    return round(sum(1 for f in fs if f.busy) / len(fs), 3)


async def _stats_models(timeout: float = 8.0) -> list:
    """标准自报块: 模型目录(hub 聚合 /v1/models 的数据源)。
    有缓存直接给(容忍过期); 无缓存现拉一次(失败给空, 路由不受影响 —— deepinfra 是默认目录)。"""
    if not _models_cache.get("entry"):
        try:
            r = await upstream_client.get(f"{UPSTREAM}/v1/openai/models",
                                          timeout=timeout)
            _models_cache["entry"] = {"key": "/v1/openai/models",
                                      "ts": time.monotonic(),
                                      "status": r.status_code,
                                      "body": r.content}
        except Exception:
            return []
    try:
        entry = _models_cache.get("entry") or {}
        data = json.loads(entry.get("body", b"{}")).get("data", [])
        return [m.get("id") for m in data if m.get("id")]
    except Exception:
        return []

SIGN_FAIL_RESTART = 5        # 工人页连续签发失败 N 次则回收, 看门狗负责补齐
ADAPTIVE_WINDOW   = 300      # 自适应货架的需求统计窗口(秒)
RATE_LIMIT_RPM    = cfg.RATE_LIMIT_RPM   # 每 key 每分钟限次(0=关)

GET_TOKEN_JS = """
async (sitekey) => {
  if (!window.turnstile) throw new Error('window.turnstile 未加载');
  const el = document.createElement('div');
  el.style.display = 'none';
  document.body.appendChild(el);
  return await new Promise((resolve, reject) => {
    const wid = window.turnstile.render(el, {
      sitekey,
      callback: (t) => { window.turnstile.remove(wid); el.remove(); resolve(t); },
      'error-callback': (e) => reject(new Error('turnstile error: ' + e)),
      'expired-callback': () => reject(new Error('expired')),
    });
  });
}
"""

BASE_HEADERS = {
    "User-Agent": UA,
    "Origin": "https://deepinfra.com",
    "Referer": "https://deepinfra.com/",
    "X-Deepinfra-Source": "model-embed",
    "Accept": "text/event-stream",
    "Content-Type": "application/json",
}

# ------------------------- 空壳工厂页(intercept/mock 模式) -------------------------
FACTORY_MODES = ("intercept", "mock", "real")

SHELL_HTML = """<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
<title>DeepInfra</title>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body>
<div style="font:14px/1.6 sans-serif;padding:24px;color:#333">
<h1 style="font-size:16px">DeepInfra</h1>
<p>Chat with AI models.</p>
<textarea aria-label="chatbot input prompt" rows="1" style="width:320px"></textarea>
</div>
</body></html>"""


def shell_page_url(factory_page: str) -> str:
    """空壳页导航 URL: 保持目标站 origin(路径随意, 仅供人看)。"""
    host = urlparse(factory_page).netloc or "deepinfra.com"
    return f"https://{host}/__shell__"


# ---- mock 模式: 本地 HTTPS 空壳页服务(自签证书, 容器内 loopback) ----
_mock_server_started = False


def _ensure_mock_cert() -> tuple[str, str]:
    """自签一张 <host> 证书, 缓存在 PROFILE_DIR 旁; 幂等。"""
    cert_dir = Path(cfg.PROFILE_DIR).parent / "relay-mock-cert"
    cert_pem, key_pem = cert_dir / "cert.pem", cert_dir / "key.pem"
    if cert_pem.exists() and key_pem.exists():
        return str(cert_pem), str(key_pem)
    cert_dir.mkdir(parents=True, exist_ok=True)
    host = urlparse(DEFAULT_FACTORY_PAGE).netloc or "deepinfra.com"
    from cryptography import x509
    from cryptography.hazmat.primitives import hashes, serialization
    from cryptography.hazmat.primitives.asymmetric import rsa
    from cryptography.x509.oid import NameOID
    import datetime
    key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, host)])
    now = datetime.datetime.now(datetime.timezone.utc)
    cert = (x509.CertificateBuilder()
            .subject_name(name).issuer_name(name)
            .public_key(key.public_key())
            .serial_number(x509.random_serial_number())
            .not_valid_before(now - datetime.timedelta(days=1))
            .not_valid_after(now + datetime.timedelta(days=3650))
            .add_extension(x509.SubjectAlternativeName(
                [x509.DNSName(host)]), critical=False)
            .sign(key, hashes.SHA256()))
    cert_pem.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
    key_pem.write_bytes(key.private_bytes(
        serialization.Encoding.PEM,
        serialization.PrivateFormat.TraditionalOpenSSL,
        serialization.NoEncryption()))
    return str(cert_pem), str(key_pem)


def ensure_mock_server():
    """启动 mock 模式的本地 HTTPS 服务(幂等, 线程化)。"""
    global _mock_server_started
    if _mock_server_started:
        return
    cert_pem, key_pem = _ensure_mock_cert()

    class _Handler(BaseHTTPRequestHandler):
        def do_GET(self):
            body = SHELL_HTML.encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def log_message(self, *a):
            pass

    httpd = ThreadingHTTPServer(("127.0.0.1", cfg.MOCK_PORT), _Handler)
    sctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    sctx.load_cert_chain(cert_pem, key_pem)
    httpd.socket = sctx.wrap_socket(httpd.socket, server_side=True)
    threading.Thread(target=httpd.serve_forever, daemon=True).start()
    _mock_server_started = True
    log(f"[mock] local HTTPS shell page on 127.0.0.1:{cfg.MOCK_PORT}")

_started_at = time.time()


def log(*a):
    print(time.strftime("[%H:%M:%S]"), *a, flush=True)


# ------------------------- 工人页(签发器) -------------------------
class TokenFactory:
    """一个实例 = 一个常驻浏览器标签页 (同一工厂模型页)。
    身份只有签发并发意义; 签出的 token 对全部可路由模型通用。"""
    def __init__(self, wid: int):
        self.wid = wid
        self.page = None
        self.busy = False          # 正在签发中
        self.retired = False       # 缩容标记: 不再派活, 闲下来即关闭
        self.consecutive_fails = 0 # 连续签发失败计数(健康度; 成功即清零)
        self.stats = {"signed": 0, "sign_failed": 0, "last_sign_latency": None}
        self.latencies = collections.deque(maxlen=20)   # 最近签发耗时(秒)

    async def start(self, ctx, page_url: str):
        """按 pool.factory_mode 建页:
        - real:      goto 真实工厂页(page_url), 等 turnstile 懒加载(带兜底)
        - intercept: route 拦截目标站导航, 空壳页纯内存承载(零基础设施)
        - mock:      goto 空壳页 URL(DNS 由启动参数映射到本地 HTTPS 服务)
        """
        if self.page:
            try:
                await self.page.close()
            except Exception:
                pass
        self.page = await ctx.new_page()
        mode = pool.factory_mode
        if mode in ("intercept", "mock"):
            shell_url = shell_page_url(pool.factory_page)
            if mode == "intercept":
                async def _fulfill(route):
                    await route.fulfill(
                        status=200, content_type="text/html; charset=utf-8",
                        body=SHELL_HTML)
                host = urlparse(pool.factory_page).netloc or "deepinfra.com"
                await self.page.route(f"https://{host}/**", _fulfill)
            else:
                ensure_mock_server()
            await self.page.goto(shell_url, wait_until="domcontentloaded",
                                 timeout=60000)
            # 冷 profile 首次加载 api.js 有 302 跳转链, 可能超 15s;
            # warm 后(同 profile 后续工人页)亚秒就绪。60s 是安全上限。
            await self.page.wait_for_function("() => !!window.turnstile",
                                               timeout=60000)
        else:
            await self.page.goto(page_url, wait_until="domcontentloaded",
                                 timeout=180000)
            try:
                await self.page.wait_for_function("() => !!window.turnstile",
                                                  timeout=15000)
            except Exception:
                try:
                    box = self.page.locator(
                        'textarea[aria-label="chatbot input prompt"]')
                    await box.wait_for(state="visible", timeout=20000)
                    await box.click()
                except Exception:
                    pass
                await self.page.wait_for_function("() => !!window.turnstile",
                                                  timeout=60000)
        await self.page.wait_for_timeout(2500)
        log(f"[worker {self.wid}] ready (mode={mode})")

    async def sign(self):
        """在本页执行一次 Turnstile 验证并返回 token; 失败返回 None。"""
        self.busy = True
        t0 = time.monotonic()
        try:
            token = await self.page.evaluate(GET_TOKEN_JS, SITEKEY)
            dt = round(time.monotonic() - t0, 2)
            self.stats["signed"] += 1
            self.stats["last_sign_latency"] = dt
            self.latencies.append(dt)
            self.consecutive_fails = 0
            return token
        except Exception as e:
            self.stats["sign_failed"] += 1
            self.consecutive_fails += 1
            log(f"[worker {self.wid}] sign failed: {str(e).splitlines()[0]}")
            return None
        finally:
            self.busy = False

    async def stop(self):
        self.retired = True
        if self.page:
            try:
                await self.page.close()
            except Exception:
                pass
            self.page = None

    @property
    def ready(self):
        return self.page is not None and not self.retired


# --------------------------- 共享 Token 池 ---------------------------
class TokenPool:
    def __init__(self):
        self.factories: dict[int, TokenFactory] = {}
        self._next_wid = 0
        # 运行时可热改的参数(从 config 拷贝; setter 会唤醒补货循环立即生效)
        self.relay_keys: set[str] = set(cfg.RELAY_KEYS)   # 来自 RELAY_KEYS 环境变量
        self.worker_pages = DEFAULT_WORKER_PAGES
        self.max_worker_pages = DEFAULT_MAX_WORKER_PAGES
        self.shelf_size = DEFAULT_SHELF_SIZE
        self.token_max_age = DEFAULT_TOKEN_MAX_AGE
        self.idle_pause = DEFAULT_IDLE_PAUSE
        self.max_retry = DEFAULT_MAX_RETRY              # 上游错误重试次数(0=不重试)
        self.retry_statuses: set[int] = set()
        self.set_retry_statuses(DEFAULT_RETRY_STATUSES)   # 字符串 "403,429,..." → int 集合
        self.desired_pages = self.worker_pages
        self.factory_page: str = DEFAULT_FACTORY_PAGE   # 工厂页(面板可热切换)
        self.factory_mode: str = (cfg.FACTORY_MODE if cfg.FACTORY_MODE
                                  in FACTORY_MODES else "intercept")
        self.shelf = collections.deque()        # [(token, issued_at)] 全局共享
        self.cond = asyncio.Condition()
        self.inflight = 0                       # 进行中的签发数
        self.last_use = time.monotonic()
        self.ready = False
        self.channel = None                     # 实际使用的浏览器通道
        self._stopping = False
        self._scale_lock = asyncio.Lock()       # 防 keeper 与 /api/pool/scale 并发扩缩容
        self._keeper_task = None                # 工人页看门狗任务
        # 可靠性/配额治理
        self.adaptive_shelf = cfg.ADAPTIVE_SHELF
        self.browser_max_age = cfg.BROWSER_MAX_AGE
        self.issue_timeout = cfg.ISSUE_TIMEOUT
        self.last_error: str | None = None      # 最近一次池级故障摘要(状态页可见)
        self.served_ts = collections.deque()    # 最近取货时刻(自适应货架需求统计)
        self._browser_started: float | None = None
        self._recycling = False
        # 运行时观测
        self.served = 0
        self.surplus_dropped = 0
        self.expired_dropped = 0
        self.requests = collections.deque(maxlen=50)
        # token 用量计数(2026-10-02 对账体系): 空间是唯一寿命 ≥ 流的观测者,
        # 这里累计的总量是 hub 侧 usage 抓取(长流会被 Workers 掐死)的完整兜底。
        # 重启清零, hub 侧用快照差值(delta)取窗口量, 回绕按 0 处理(与 signed_total 同法)。
        self.usage_in = 0      # prompt_tokens 累计
        self.usage_out = 0     # completion_tokens 累计

    def add_usage(self, usage: dict | None):
        """把一笔请求的 usage 记入累计器(缺字段/异常静默, 绝不影响转发)。"""
        try:
            if usage:
                self.usage_in += int(usage.get("prompt_tokens") or 0)
                self.usage_out += int(usage.get("completion_tokens") or 0)
        except Exception:
            pass

    # ---------- 运行时热改参数(setter 即时生效) ----------
    def _wake(self):
        """唤醒补货循环。注意两个坑: asyncio.create_task 只收 coroutine;
        Condition.notify_all 必须持锁调用。旧实现 create_task(self.cond.notify_all())
        原地抛 TypeError 且 except 只接 RuntimeError —— 面板热改
        token_max_age/idle_pause 会 500, 已修复为包一层协程。"""
        async def _notify():
            async with self.cond:
                self.cond.notify_all()
        try:
            asyncio.create_task(_notify())
        except RuntimeError:
            pass   # 无事件循环时(初始化期)忽略

    def set_shelf_size(self, n: int):
        self.shelf_size = max(0, int(n))
        # 收紧时立即丢弃超出部分(避免慢慢等过期), 算入 surplus_dropped
        async def _trim():
            async with self.cond:
                while len(self.shelf) > self.shelf_size:
                    self.shelf.popleft()
                    self.surplus_dropped += 1
                self.cond.notify_all()
        try:
            asyncio.create_task(_trim())
        except RuntimeError:
            pass

    def set_token_max_age(self, n: int):
        self.token_max_age = max(1, int(n))
        self._wake()

    def set_idle_pause(self, n: int):
        self.idle_pause = max(0, int(n))
        self.last_use = time.monotonic()      # 改静默参数视同"有活动", 立即解静默
        self._wake()

    def set_max_retry(self, n: int):
        """重试次数: 任意非负整数, 不设上限(0=不重试)。"""
        self.max_retry = max(0, int(n))

    def set_retry_statuses(self, value):
        """重试状态码集合: 逗号分隔字符串或列表, 如 "403,429,502"。空/非法项忽略。"""
        if isinstance(value, str):
            parts = value.replace(" ", "").split(",")
        elif isinstance(value, (list, tuple)):
            parts = [str(v) for v in value]
        else:
            return False
        codes = set()
        for p in parts:
            try:
                codes.add(int(p))
            except ValueError:
                pass
        if not codes:
            return False
        self.retry_statuses = codes
        return True

    def set_max_worker_pages(self, n: int):
        self.max_worker_pages = max(1, int(n))
        # 若新的硬上限低于当前期望, 顺势下调
        if self.desired_pages > self.max_worker_pages:
            asyncio.create_task(self.scale(self.max_worker_pages))

    # ---------- 生命周期 ----------
    async def _launch_browser(self):
        """启动浏览器上下文。容器/本地自适应:
        - 容器(HF_SPACE / 无显示器): headless 由 cfg.HEADLESS 决定; Xvfb 在 Dockerfile
          外层提供虚拟显示, 这里仅加 --no-sandbox 等容器化参数。mdedge 通道在容器里
          不会有, 直接试 bundled chromium, 失败再退一次。
        - 本机(有显示器): headless=False 保指纹, 复用 POC 验证过的 msedge→chromium 降级。
        """
        import shutil
        last_err = None

        # 容器化参数: 非 root 跑必须 --no-sandbox; /dev/shm 太小会崩, 改用 /tmp。
        extra_args = ["--disable-blink-features=AutomationControlled"]
        if cfg.HF_SPACE:
            extra_args += ["--no-sandbox",
                           "--disable-dev-shm-usage",
                           "--disable-gpu"]
        if cfg.PROXY:
            extra_args.append(f"--proxy-server={cfg.PROXY}")
        if self.factory_mode == "mock":
            # DNS 映射: 目标站 -> 本地空壳页服务(真实站流量从浏览器消失;
            # httpx 走的 api 子域不受影响)
            extra_args.append(
                f"--host-resolver-rules=MAP "
                f"{urlparse(self.factory_page).netloc or 'deepinfra.com'} "
                f"127.0.0.1:{cfg.MOCK_PORT}")

        # 本机可走 msedge 降级; 容器里没装 edge, 只试 bundled chromium。
        channels = ("msedge", None) if not cfg.HF_SPACE else (None,)
        profile = cfg.PROFILE_DIR

        for channel in channels:
            for attempt in range(2):
                try:
                    kwargs = dict(headless=cfg.HEADLESS,
                                  args=extra_args,
                                  viewport={"width": 1280, "height": 900})
                    if self.factory_mode == "mock":
                        kwargs["ignore_https_errors"] = True  # 本地自签证书
                    if channel:
                        kwargs["channel"] = channel
                    self.ctx = await self._pw.chromium.launch_persistent_context(
                        profile, **kwargs)
                    self.channel = channel or "bundled"
                    self._browser_started = time.monotonic()
                    log(f"[pool] browser launched "
                        f"(channel={self.channel} headless={cfg.HEADLESS} "
                        f"hf={cfg.HF_SPACE})")
                    return
                except Exception as e:
                    last_err = e
                    log(f"[pool] launch failed (channel={channel or 'bundled'} "
                        f"attempt {attempt+1}): {str(e).splitlines()[0]}")
                    await asyncio.sleep(4)
            shutil.rmtree(profile, ignore_errors=True)
        raise RuntimeError(f"browser launch failed, last error: {last_err}")

    async def supervisor(self):
        """守护循环: 浏览器上线 → 拉起工人页 + 补货协程 + 看门狗。"""
        while not self._stopping:
            self.ready = False
            try:
                self._pw = await async_playwright().start()
                await self._launch_browser()
                self._refiller = asyncio.create_task(self._refill_loop())
                await self._apply_scale()        # 建工人页(可后台继续, 就绪不依赖)
                self.ready = True
                if self.factories:
                    self.last_error = None       # 本轮至少有工人页建成, 清除旧故障标记
                self._start_keeper()             # 看门狗: 工人页失败/丢失后自动补齐
                log(f"[pool] READY ({len(self.factories)} worker pages)")
                return
            except Exception as e:
                self.last_error = f"supervisor cycle failed: {str(e).splitlines()[0]}"
                log(f"[pool] {self.last_error}")
            await self._teardown()
            if not self._stopping:
                await asyncio.sleep(15)

    async def _teardown(self):
        for f in self.factories.values():
            try:
                await f.stop()
            except Exception:
                pass
        self.factories.clear()
        if getattr(self, "ctx", None):
            try:
                await self.ctx.close()
            except Exception:
                pass
        self.ctx = None
        if getattr(self, "_pw", None):
            try:
                await self._pw.stop()
            except Exception:
                pass
        self._pw = None

    async def stop(self):
        self._stopping = True
        await self._teardown()

    # ---------- 工人页扩缩容 ----------
    async def _apply_scale(self):
        """把工人页数量调整到 desired_pages。(加锁: 看门狗/API 触发的扩缩容可能并发)"""
        async with self._scale_lock:
            n = self.desired_pages
            # 需要新增
            while len(self.factories) < n:
                self._next_wid += 1
                f = TokenFactory(self._next_wid)
                self.factories[f.wid] = f
                try:
                    await f.start(self.ctx, self.factory_page)
                except Exception as e:
                    self.last_error = (f"worker {f.wid} start failed: "
                                       f"{str(e).splitlines()[0]}")
                    log(f"[pool] {self.last_error}")
                    del self.factories[f.wid]
                    break                # 本次放弃, 由看门狗稍后自动重试
                async with self.cond:
                    self.cond.notify_all()
            # 需要缩减: 优先退役闲置页; 忙页打标记, 由补货循环收尾
            actives = [f for f in self.factories.values() if not f.retired]
            for f in actives[:max(0, len(actives) - n)]:
                if not f.busy:
                    await f.stop()
                    self.factories.pop(f.wid, None)
                    log(f"[pool] worker {f.wid} retired (idle)")
                else:
                    f.retired = True
                    log(f"[pool] worker {f.wid} will retire after current sign")

    def _start_keeper(self):
        """启动(或复用)工人页看门狗任务。"""
        if self._keeper_task and not self._keeper_task.done():
            return
        self._keeper_task = asyncio.create_task(self._keeper_loop())

    async def _keeper_loop(self):
        """看门狗: ①存活工人页少于期望时周期性重试扩容; ②浏览器超龄时趁空闲回收。

        修复的缺陷: v2 里 _apply_scale 首个工人页启动失败即 break,
        supervisor 随即置 ready=True 返回——此后没有任何代码再尝试建页,
        池子进入"ready 但 0 工人"的僵尸态, 所有请求等 token 60s 后 503。
        (冷启动时工厂页偶发一次加载失败即可触发, 只能重启或手动 scale 解除。)
        """
        while not self._stopping:
            await asyncio.sleep(30)
            if self._stopping:
                continue
            if not getattr(self, "ctx", None):
                # 浏览器丢了(如回收失败): 直接重建浏览器 + 工人页, 不能干等
                if self.ready and not self._recycling:
                    log("[keeper] browser context missing, relaunching")
                    asyncio.create_task(self._recycle_browser())
                continue
            # 浏览器定期回收: 只在无在签/无忙页的空闲窗口执行
            if (self._browser_started and not self._recycling
                    and self.inflight == 0
                    and all(not f.busy for f in self.factories.values())
                    and time.monotonic() - self._browser_started
                        > self.browser_max_age):
                asyncio.create_task(self._recycle_browser())
                continue
            alive = sum(1 for f in self.factories.values() if not f.retired)
            if alive < self.desired_pages:
                log(f"[keeper] workers {alive}/{self.desired_pages}, "
                    f"retrying scale-up")
                try:
                    await self._apply_scale()
                    alive = sum(1 for f in self.factories.values()
                                if not f.retired)
                    if alive >= self.desired_pages:
                        self.last_error = None    # 补齐成功, 清除故障标记
                except Exception as e:
                    log(f"[keeper] rescale failed: {str(e).splitlines()[0]}")

    async def _recycle_browser(self):
        """定期回收: 长跑 Chromium 会累积内存和 profile 膨胀。
        货架上的 token 是纯字符串, 回收浏览器不清现货, 请求面基本无感。"""
        self._recycling = True
        try:
            age = int(time.monotonic() - (self._browser_started or 0))
            log(f"[pool] periodic browser recycle (age={age}s)")
            await self._teardown()
            self._pw = await async_playwright().start()
            await self._launch_browser()
            await self._apply_scale()
            if self.factories:
                self.last_error = None
            log(f"[pool] browser recycled "
                f"({len(self.factories)} worker pages, shelf kept "
                f"{len(self.shelf)})")
        except Exception as e:
            self.last_error = f"browser recycle failed: {str(e).splitlines()[0]}"
            log(f"[pool] {self.last_error}")
        finally:
            self._recycling = False

    async def scale(self, pages: int) -> dict:
        self.desired_pages = max(1, min(self.max_worker_pages, int(pages)))
        if getattr(self, "ctx", None):
            asyncio.create_task(self._apply_scale())
        return {"desired": self.desired_pages,
                "active": sum(1 for f in self.factories.values()
                              if not f.retired)}

    # ---------- 工厂页热切换 ----------
    def _validate_factory_page(self, url) -> str:
        """工厂页 URL 校验。接受三种写法:
        ① 完整 URL https://deepinfra.com/<owner>/<model>
        ② 站内路径 /<owner>/<model>
        ③ 裸模型 id <owner>/<model>"""
        url = str(url or "").strip()
        if not url:
            raise ValueError("empty factory_page")
        if "/" in url and not url.startswith(("http://", "https://", "/")) \
                and " " not in url:
            url = f"/{url}"                       # 裸 owner/model → 路径
        if url.startswith("/"):
            url = f"https://deepinfra.com{url}"
        if not (url.startswith("https://") or url.startswith("http://")):
            raise ValueError("factory_page must be url or /owner/model")
        return url

    async def switch_factory_page(self, url) -> dict:
        """热切换工厂页: 现有工人页全部退役, 立即按新页重建到 desired_pages。
        货架现货 token 不受影响(签发的 token 对所有可路由模型通用)。
        intercept/mock 模式下仅取其 host 作为空壳页 origin, 路径无实际意义。"""
        url = self._validate_factory_page(url)
        if url == self.factory_page:
            return {"factory_page": url, "changed": False,
                    "active": sum(1 for f in self.factories.values()
                                  if not f.retired)}
        self.factory_page = url
        busy_retired = 0
        # 退役全部现有工人页(闲页立即关, 忙页打标记由签发收尾/看门狗处理)
        for f in list(self.factories.values()):
            f.retired = True
            if not f.busy:
                try:
                    await f.stop()
                except Exception:
                    pass
                self.factories.pop(f.wid, None)
            else:
                busy_retired += 1
        if getattr(self, "ctx", None):
            await self._apply_scale()     # 立即按新 factory_page 重建
        log(f"[pool] factory page switched -> {url} "
            f"(rebuilt {sum(1 for f in self.factories.values() if not f.retired)}"
            f"/{self.desired_pages}, {busy_retired} busy retiring)")
        return {"factory_page": url, "changed": True,
                "active": sum(1 for f in self.factories.values()
                              if not f.retired)}

    async def switch_factory_mode(self, mode) -> dict:
        """热切工厂页承载模式: intercept / mock / real。
        退役全部工人页(货架现货保留, token 通用)。
        mock <-> 其他 的切换会改变浏览器启动参数(host 映射 flag 与
        证书豁免), 需整浏览器回收重建 —— _recycle_browser 里货架不清,
        请求面基本无感; intercept <-> real 只需按新模式重建工人页。"""
        mode = str(mode or "").strip().lower()
        if mode not in FACTORY_MODES:
            raise ValueError(f"factory_mode must be one of {FACTORY_MODES}")
        if mode == self.factory_mode:
            return {"factory_mode": mode, "changed": False,
                    "active": sum(1 for f in self.factories.values()
                                  if not f.retired)}
        old = self.factory_mode
        self.factory_mode = mode
        for f in list(self.factories.values()):
            f.retired = True
            if not f.busy:
                try:
                    await f.stop()
                except Exception:
                    pass
                self.factories.pop(f.wid, None)
        needs_relaunch = (old == "mock") != (mode == "mock")
        if needs_relaunch and getattr(self, "ctx", None):
            if not self._recycling:
                log(f"[pool] factory mode {old} -> {mode}: "
                    f"launch args changed, recycling browser")
                asyncio.create_task(self._recycle_browser())
        elif getattr(self, "ctx", None):
            await self._apply_scale()          # 按新模式重建工人页
        log(f"[pool] factory mode switched: {old} -> {mode}")
        return {"factory_mode": mode, "changed": True,
                "active": sum(1 for f in self.factories.values()
                              if not f.retired)}

    # ---------- 签发与取货 ----------
    def _purge_expired(self):
        now = time.monotonic()
        while self.shelf and now - self.shelf[0][1] > self.token_max_age:
            self.shelf.popleft()
            self.expired_dropped += 1

    def _target_stock(self) -> int:
        """自适应货架目标: 按近 ADAPTIVE_WINDOW 秒的取货速率 × 平均签发时延
        估算"一轮签发周期内会来多少需求", +1 兜底, 封顶 SHELF_SIZE。
        关掉(adaptive_shelf=False)则退回固定 SHELF_SIZE 行为。"""
        if not self.adaptive_shelf:
            return self.shelf_size
        now = time.monotonic()
        while self.served_ts and now - self.served_ts[0] > ADAPTIVE_WINDOW:
            self.served_ts.popleft()
        lat = [x for f in self.factories.values() for x in f.latencies]
        avg_lat = (sum(lat) / len(lat)) if lat else 8.0      # 无样本按 8s 估
        qps = len(self.served_ts) / ADAPTIVE_WINDOW
        return max(1, min(self.shelf_size, math.ceil(qps * avg_lat) + 1))

    async def _sign_and_stock(self, f: TokenFactory):
        token = await f.sign()
        async with self.cond:
            self.inflight -= 1
            if (token and not self._stopping
                    and len(self.shelf) < self._target_stock()):
                self.shelf.append((token, time.monotonic()))
            elif token:
                self.surplus_dropped += 1       # 需求尖峰造成的超产
            self.cond.notify_all()
        if f.retired and not f.busy:            # 缩容收尾
            await f.stop()
            self.factories.pop(f.wid, None)
            log(f"[pool] worker {f.wid} retired (after sign)")
        if not token:
            await asyncio.sleep(3)              # 签发失败退避
            # 连续失败视为死页: 回收掉, 由看门狗建新页顶替
            if f.consecutive_fails >= SIGN_FAIL_RESTART and f.wid in self.factories:
                self.last_error = (f"worker {f.wid}: {f.consecutive_fails} "
                                   f"consecutive sign failures, page recycled")
                log(f"[pool] {self.last_error}")
                try:
                    await f.stop()
                finally:
                    self.factories.pop(f.wid, None)

    async def _refill_loop(self):
        """货架不足且非闲置期时, 把签发任务派给空闲工人页(并发度=空闲页数)。"""
        while not self._stopping:
            async with self.cond:
                self._purge_expired()
                idle = (time.monotonic() - self.last_use > self.idle_pause)
                deficit = self._target_stock() - len(self.shelf) - self.inflight
                while not idle and deficit > 0:
                    free = next((f for f in self.factories.values()
                                 if f.ready and not f.busy), None)
                    if free is None:
                        break
                    free.busy = True              # 占位, 防重复派单
                    self.inflight += 1
                    deficit -= 1
                    asyncio.create_task(self._sign_and_stock(free))
                try:
                    await asyncio.wait_for(self.cond.wait(), timeout=30)
                except asyncio.TimeoutError:
                    pass                          # 周期性自醒: 过期清理/闲置判断

    async def issue(self, timeout=None) -> str:
        """取一个新鲜 token: 有现货秒回, 无现货等补货(上限 ISSUE_TIMEOUT 可配)。"""
        # 空池快速失败: 无存活工人页且非浏览器回收中 → 继续等无意义
        # (看门狗 ≥30s 才可能扩容), 立即超时让 CF failover 马上切走, 而不是挂满 ISSUE_TIMEOUT
        if not self._recycling and not any(
                not f.retired for f in self.factories.values()):
            raise TimeoutError("无存活工人页(池恢复中), 快速失败")
        timeout = self.issue_timeout if timeout is None else timeout
        deadline = time.monotonic() + timeout
        async with self.cond:
            self.last_use = time.monotonic()    # 有需求, 解除闲置静默
            while True:
                self._purge_expired()
                if self.shelf:
                    token, _ = self.shelf.popleft()
                    self.served += 1
                    self.served_ts.append(time.monotonic())   # 自适应货架统计
                    self.cond.notify_all()
                    return token
                remaining = deadline - time.monotonic()
                if remaining <= 0:
                    raise TimeoutError("等待 token 补货超时")
                self.cond.notify_all()
                try:
                    await asyncio.wait_for(self.cond.wait(), timeout=remaining)
                except asyncio.TimeoutError:
                    raise TimeoutError("等待 token 补货超时")

    # ---------- 观测 ----------
    def log_request(self, entry: dict):
        entry["ts"] = time.strftime("%H:%M:%S")
        self.requests.appendleft(entry)

    def stats(self) -> dict:
        now = time.monotonic()
        lat = [x for f in self.factories.values() for x in f.latencies]
        return {
            "ready": self.ready,
            "uptime_s": int(time.time() - _started_at),
            "browser_channel": self.channel,
            "browser_age_s": (int(now - self._browser_started)
                              if self._browser_started else None),
            "last_error": self.last_error,
            "recycling": self._recycling,
            "shelf": {
                "stock": len(self.shelf), "capacity": self.shelf_size,
                "target": self._target_stock(),
                "adaptive": self.adaptive_shelf,
                "oldest_age_s": (round(now - self.shelf[0][1], 1)
                                 if self.shelf else None),
                "inflight_signs": self.inflight,
                "served_total": self.served,
                "expired_dropped": self.expired_dropped,
                "surplus_dropped": self.surplus_dropped,
            },
            "signing": {
                "signed_total": sum(f.stats["signed"]
                                    for f in self.factories.values()),
                "failed_total": sum(f.stats["sign_failed"]
                                    for f in self.factories.values()),
                "avg_latency": (round(sum(lat) / len(lat), 2) if lat else None),
                "recent_latencies": lat[-30:],
            },
            "usage": {
                "prompt_tokens_total": self.usage_in,
                "completion_tokens_total": self.usage_out,
            },
            "workers": [{
                "id": f.wid,
                "state": ("retiring" if f.retired else
                          "busy" if f.busy else
                          "ready" if f.ready else "starting"),
                "busy": f.busy,
                "signed": f.stats["signed"],
                "sign_failed": f.stats["sign_failed"],
                "consecutive_fails": f.consecutive_fails,
                "last_sign_latency": f.stats["last_sign_latency"],
                "latencies": list(f.latencies),
            } for f in sorted(self.factories.values(), key=lambda x: x.wid)],
            "workers_desired": self.desired_pages,
            "workers_max": self.max_worker_pages,
            "config": {
                "shelf_size": self.shelf_size,
                "token_max_age": self.token_max_age,
                "idle_pause": self.idle_pause,
                "max_retry": self.max_retry,
                "retry_statuses": ",".join(map(str, sorted(self.retry_statuses))),
                "worker_pages_init": DEFAULT_WORKER_PAGES,
                "max_worker_pages": self.max_worker_pages,
                "factory_page": self.factory_page,
                "factory_mode": self.factory_mode,
            },
        }

    def stats_lite(self) -> dict:
        """快照字段子集(与 /api/stats 同名同构) —— /health 附带它,
        CF keeper 直接从 /health 落快照, 每空间省一次 /api/stats 子请求"""
        return {
            "last_error": self.last_error,
            "shelf": {"stock": len(self.shelf), "capacity": self.shelf_size,
                      "target": self._target_stock(), "served_total": self.served},
            "signing": {"signed_total": sum(f.stats["signed"] for f in self.factories.values()),
                        "failed_total": sum(f.stats["sign_failed"] for f in self.factories.values())},
            "workers": [{"state": ("retiring" if f.retired else "busy" if f.busy
                                   else "ready" if f.ready else "starting")}
                        for f in self.factories.values()],
            "workers_desired": self.desired_pages,
            "usage": {"prompt_tokens_total": self.usage_in,
                      "completion_tokens_total": self.usage_out},
        }


pool = TokenPool()
# openapi_url=None: 不暴露 /docs /redoc /openapi.json, 避免公开 API 结构
app = FastAPI(title="app",
              docs_url=None, redoc_url=None, openapi_url=None)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"],
                   allow_headers=["*"])

# 共享上游连接池(连接复用; 每个响应由调用方负责关闭)
upstream_client: httpx.AsyncClient | None = None


@app.on_event("startup")
async def _startup():
    global upstream_client
    upstream_client = httpx.AsyncClient(
        timeout=300, limits=httpx.Limits(max_connections=64,
                                         max_keepalive_connections=16))
    asyncio.create_task(pool.supervisor())


@app.on_event("shutdown")
async def _shutdown():
    await pool.stop()
    if upstream_client:
        await upstream_client.aclose()


def ensure_ready():
    if not pool.ready:
        raise HTTPException(503, "token factories warming up, retry shortly")


# 每 key 滑动窗口限速窗口(RATE_LIMIT_RPM=0 时关闭)
_rate_windows: dict[str, collections.deque] = {}


def check_key(authorization: str = Header(default="")) -> str:
    """客户端鉴权: OpenAI 兼容端点共用。key 来自 cfg.RELAY_KEYS (环境变量 RELAY_KEYS);
    ADMIN_KEY 亦放行 —— 单 key 部署时只设一个 key 即可全线通行。
    附带每 key 滑动窗口限速: key 泄露时可防止匿名配额被打爆。"""
    if not authorization.startswith("Bearer "):
        raise HTTPException(401, "missing bearer key")
    key = authorization[7:].strip()
    if (key not in pool.relay_keys
            and not (cfg.ADMIN_KEY and key == cfg.ADMIN_KEY)):
        raise HTTPException(401, "invalid key")
    if RATE_LIMIT_RPM > 0:
        now = time.monotonic()
        w = _rate_windows.setdefault(key, collections.deque())
        while w and now - w[0] > 60:
            w.popleft()
        if len(w) >= RATE_LIMIT_RPM:
            raise HTTPException(
                429, "relay rate limit exceeded, slow down or contact admin")
        w.append(now)
    return key


def check_admin(authorization: str = Header(default="")) -> None:
    """管理面板鉴权: 额外要求 ADMIN_KEY。若未配 ADMIN_KEY 或未启 ADMIN_ENABLED,
    所有 /admin 与写操作型 /api/* 路由直接 404/401, 防公网误触。

    HF 部署默认 ADMIN_ENABLED=false —— 即控制台完全收起。需要在线运维时:
    1) 在 Space > Settings > Repository secrets 加 ADMIN_KEY=sk-admin-xxx;
    2) 在 Variables 里设 ADMIN_ENABLED=true(或直接私有也行)。"""
    if not cfg.ADMIN_ENABLED or not cfg.ADMIN_KEY:
        raise HTTPException(404, "admin disabled")
    if not authorization.startswith("Bearer "):
        raise HTTPException(401, "missing admin bearer key")
    if authorization[7:].strip() != cfg.ADMIN_KEY:
        raise HTTPException(401, "invalid admin key")
    return None


# --------------------------- 上游调用 ---------------------------
def _retry_backoff(attempt: int) -> float:
    """第 attempt 次重试前的等待秒数: 0.5s 起步翻倍, 封顶 8s。"""
    return min(8.0, 0.5 * (2 ** attempt))


async def call_with_token(open_fn, retries=None):
    """open_fn(token) -> httpx.Response(流式开启)。
    上游响应状态码落在 retry_statuses 里 → 换新 token 重试(短退避),
    次数 pool.max_retry(0=不重试, 支持运行时热改)。最终原样返回最后一次响应。"""
    if retries is None:
        retries = pool.max_retry
    resp = None
    for attempt in range(1 + retries):
        token = await pool.issue()
        resp = await open_fn(token)
        if resp.status_code not in pool.retry_statuses:
            return resp
        if attempt < retries:        # 只有还要重试才关闭+退避, 兜底返回时留给调用方读
            await resp.aclose()
            await asyncio.sleep(_retry_backoff(attempt))
    return resp


def auth_headers(token):
    return dict(BASE_HEADERS, **{"X-DeepInfra-Turnstile": token})


# --------------------------- OpenAI 兼容路由 ---------------------------
@app.post("/v1/chat/completions")
async def chat_completions(request: Request, key: str = Depends(check_key)):
    ensure_ready()
    payload = await request.json()
    model = payload.get("model")
    want_stream = bool(payload.get("stream", False))
    payload["stream"] = True                         # 上游永远流式, 边缘适配
    payload.setdefault("stream_options", {"include_usage": True})
    t0 = time.monotonic()

    async def open_fn(token):
        return await upstream_client.send(
            upstream_client.build_request(
                "POST", f"{UPSTREAM}/v1/openai/chat/completions",
                headers=auth_headers(token), json=payload),
            stream=True)

    try:
        resp = await call_with_token(open_fn)
    except Exception as e:
        pool.log_request({"model": model, "stream": want_stream,
                          "status": 503, "err": str(e)[:120],
                          "latency_ms": int((time.monotonic() - t0) * 1000)})
        raise HTTPException(503, f"token factory unavailable: {e}")

    if resp.status_code != 200:
        body = await resp.aread()
        ctype = resp.headers.get("content-type", "application/json")
        await resp.aclose()
        pool.log_request({"model": model, "stream": want_stream,
                          "status": resp.status_code, "err": body[:120].decode(
                              "utf-8", "replace"),
                          "latency_ms": int((time.monotonic() - t0) * 1000)})
        return Response(content=body, status_code=resp.status_code,
                        media_type=ctype)              # 错误体字节级透传

    if want_stream:
        # 尾部缓冲: usage 只在 SSE 结尾统计块里(stream_options.include_usage),
        # 原始块 O(1) 入窗, 流结束只解析最后 ~64KB —— 计数器是 hub 长流 usage
        # 丢失的对账兜底, 提取失败也绝不影响转发。
        tail = collections.deque()
        tail_len = 0

        def _drain_usage():
            try:
                text = b"".join(tail).decode("utf-8", "replace")
                for line in reversed(text.split("\n")):
                    line = line.strip()
                    if not line.startswith("data:") or '"usage"' not in line:
                        continue
                    try:
                        pool.add_usage(json.loads(line[5:].strip()).get("usage"))
                    except Exception:
                        pass
                    break
            finally:
                tail.clear()
        async def sse_passthrough():
            nonlocal tail_len
            try:
                async for chunk in resp.aiter_raw():    # 原样转发, 全保真
                    tail.append(chunk)
                    tail_len += len(chunk)
                    while tail_len - len(tail[0]) >= 65536 and len(tail) > 1:
                        tail_len -= len(tail.popleft())
                    yield chunk
            finally:
                await resp.aclose()
                _drain_usage()
                pool.log_request({"model": model, "stream": True,
                                  "status": 200, "err": None,
                                  "latency_ms": int(
                                      (time.monotonic() - t0) * 1000)})
        return StreamingResponse(sse_passthrough(),
                                 media_type="text/event-stream")

    # 非流式: 聚合 delta.content / reasoning_content / tool_calls, 透传真实 finish_reason
    text, reasoning, usage = [], [], None
    tool_calls: dict[int, dict] = {}          # index -> 累积中的 tool call 片段
    finish_reason = None
    async for line in resp.aiter_lines():
        if not line.startswith("data:"):
            continue
        data = line[5:].strip()
        if data == "[DONE]":
            break
        try:
            c = json.loads(data)
            for ch in c.get("choices", []):
                d = ch.get("delta", {})
                if d.get("reasoning_content"):
                    reasoning.append(d["reasoning_content"])
                if d.get("content"):
                    text.append(d["content"])
                for tc in d.get("tool_calls") or []:
                    slot = tool_calls.setdefault(tc.get("index", 0), {
                        "id": "", "type": "function",
                        "function": {"name": "", "arguments": ""}})
                    if tc.get("id"):
                        slot["id"] = tc["id"]
                    fn = tc.get("function") or {}
                    if fn.get("name"):
                        slot["function"]["name"] += fn["name"]
                    if fn.get("arguments"):
                        slot["function"]["arguments"] += fn["arguments"]
                if ch.get("finish_reason"):
                    finish_reason = ch["finish_reason"]
            if c.get("usage"):
                usage = c["usage"]
        except Exception:
            pass
    await resp.aclose()
    pool.add_usage(usage)
    pool.log_request({"model": model, "stream": False, "status": 200,
                      "err": None,
                      "latency_ms": int((time.monotonic() - t0) * 1000)})
    msg = {"role": "assistant",
           "content": "".join(text) if (text or not tool_calls) else None}
    if reasoning:
        msg["reasoning_content"] = "".join(reasoning)
    if tool_calls:
        msg["tool_calls"] = [
            {"id": v["id"] or f"call_{idx}", "type": "function",
             "function": v["function"]}
            for idx, v in sorted(tool_calls.items())
        ]
    if not finish_reason:
        finish_reason = "tool_calls" if tool_calls else "stop"
    return {
        "id": "chatcmpl-relay", "object": "chat.completion",
        "created": int(time.time()),
        "model": model,
        "choices": [{"index": 0, "message": msg, "finish_reason": finish_reason}],
        "usage": usage,
    }


async def passthrough_json(request: Request, upstream_path: str):
    """embeddings / completions 等: 原样转发, 不搞边缘聚合。"""
    ensure_ready()
    payload = await request.json()

    async def open_fn(token):
        return await upstream_client.send(
            upstream_client.build_request(
                "POST", f"{UPSTREAM}{upstream_path}",
                headers=auth_headers(token), json=payload),
            stream=True)
    try:
        resp = await call_with_token(open_fn)
    except Exception as e:
        raise HTTPException(503, f"token factory unavailable: {e}")

    if payload.get("stream"):
        async def gen():
            try:
                async for chunk in resp.aiter_raw():
                    yield chunk
            finally:
                await resp.aclose()
        return StreamingResponse(gen(), media_type="text/event-stream",
                                 status_code=resp.status_code)
    body = await resp.aread()
    ctype = resp.headers.get("content-type", "application/json")
    await resp.aclose()
    return Response(content=body, status_code=resp.status_code,
                    media_type=ctype)


@app.post("/v1/embeddings")
async def embeddings(request: Request, key: str = Depends(check_key)):
    return await passthrough_json(request, "/v1/openai/embeddings")


@app.post("/v1/completions")
async def completions(request: Request, key: str = Depends(check_key)):
    return await passthrough_json(request, "/v1/openai/completions")


@app.get("/v1/models")
async def models(key: str = Depends(check_key), full: bool = False):
    """上游全量模型列表原样透传(60s 微缓存, 只为快不为筛选)。
    默认 OpenAI 格式, ?full=true 返回官网富元数据(不缓存)。"""
    path = "/models/list" if full else "/v1/openai/models"
    now = time.monotonic()
    hit = _models_cache.get("entry")
    if not full and hit and hit["key"] == path and now - hit["ts"] < MODELS_CACHE_TTL:
        return Response(content=hit["body"], status_code=hit["status"],
                        media_type="application/json")
    r = await upstream_client.get(f"{UPSTREAM}{path}", timeout=60)
    body = r.content
    if not full:
        _models_cache["entry"] = {"key": path, "ts": now,
                                  "status": r.status_code, "body": body}
    return JSONResponse(status_code=r.status_code,
                        content=json.loads(body.decode("utf-8", "replace")))


# --------------------------- 管理面板与 API ---------------------------
ADMIN_HTML = Path(__file__).with_name("admin.html")

ROOT_HTML = """<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Service</title>
<style>body{background:#f6f8fa;color:#57606a;font:14px/1.6 -apple-system,
"Segoe UI","Microsoft YaHei",sans-serif;padding:48px;max-width:640px;margin:0 auto}</style></head>
<body>
<h1 style="font-size:20px;color:#24292f">🟢 Service is running</h1>
<p>Nothing to see here.</p>
</body></html>"""


@app.get("/", response_class=HTMLResponse)
async def status_page():
    """根路径: 极简占位页, 不含任何运行数据/端点提示。"""
    return HTMLResponse(ROOT_HTML)


@app.get("/health")
async def health():
    """HF Space 探活端点。带快照字段子集(与 /api/stats 同名同构, 见 stats_lite)
    —— CF keeper 从这里直接落快照, 每空间省一次 /api/stats 子请求;
    models 也自报(hub 目录路由的动态成员 + /v1/models 聚合数据源)。
    ⚠ 曾有一次编辑事故: 函数上方残留了重复的 @app.get("/health") 装饰器(首个
    定义体只有 docstring), FastAPI 按注册顺序命中前者 → /health 返回 null,
    hub 的健康捷径静默失效回退全量拉取, ready 字段一直为空。改动此处时
    保持只有一个 /health 路由。"""
    return {"ok": pool.ready, "ready": pool.ready,
            "uptime_s": int(time.time() - _started_at),
            "load": {"saturation": _load_saturation()},
            "models": await _stats_models(),
            **pool.stats_lite()}


@app.get("/admin", response_class=FileResponse)
async def admin_page(request: Request):
    """完整管理控制台。页面骨架本身不含数据, 数据/写操作 API 各自独立鉴权。

    低成本伪装: 无 ?ui=1 查询参数时返回 404(与不存在的路由表现一致),
    避免被扫描器/随手访问者发现控制台入口; 自己访问用 /admin?ui=1。"""
    if not cfg.ADMIN_ENABLED or not cfg.ADMIN_KEY:
        raise HTTPException(404, "admin disabled")
    if request.query_params.get("ui") != "1":
        raise HTTPException(404, "Not Found")
    return FileResponse(ADMIN_HTML, media_type="text/html")


@app.get("/api/stats")
async def api_stats(key: str = Depends(check_key)):
    s = pool.stats()
    # 标准自报块(hub 平台层契约): 通用健康 + 饱和度 + 目录
    s["load"] = {"saturation": _load_saturation()}
    s["models"] = await _stats_models()
    return s


@app.get("/stats")                 # 兼容旧测试套件
async def stats_compat(key: str = Depends(check_key)):
    return pool.stats()


@app.get("/api/requests")
async def api_requests(key: str = Depends(check_key)):
    return {"requests": list(pool.requests)}


@app.post("/api/pool/scale")
async def api_scale(request: Request, _admin=Depends(check_admin)):
    body = await request.json()
    pages = int(body.get("pages", pool.desired_pages))
    return await pool.scale(pages)


# ---------- 参数热改 ----------
# 数值型参数的校验范围 + 对应 setter; hi=None 表示只设下限不封顶(如重试次数)。
CONFIG_FIELDS = {
    "shelf_size":        (0, 100000, pool.set_shelf_size),
    "token_max_age":     (1, 300,    pool.set_token_max_age),
    "idle_pause":        (0, 86400,  pool.set_idle_pause),
    "max_retry":         (0, None,   pool.set_max_retry),
    "max_worker_pages":  (1, 256,    pool.set_max_worker_pages),
}


def config_snapshot() -> dict:
    return {
        "shelf_size": pool.shelf_size,
        "token_max_age": pool.token_max_age,
        "idle_pause": pool.idle_pause,
        "max_retry": pool.max_retry,
        "retry_statuses": ",".join(map(str, sorted(pool.retry_statuses))),
        "worker_pages": pool.desired_pages,        # 调它请用 /api/pool/scale
        "max_worker_pages": pool.max_worker_pages,
        "factory_page": pool.factory_page,         # 热切换: 工人页按新页重建
        "factory_mode": pool.factory_mode,         # 热切换: intercept/mock/real
    }


@app.get("/api/config")
async def api_config_get(key: str = Depends(check_key)):
    return config_snapshot()


@app.put("/api/config")
async def api_config_put(request: Request, _admin=Depends(check_admin)):
    """批量热改参数(需 ADMIN_KEY), 只接受 CONFIG_FIELDS 里的字段; 越界/非法静默跳过并报告。"""
    body = await request.json()
    applied, skipped = {}, {}
    for name, value in body.items():
        # worker_pages / factory_page / factory_mode / retry_statuses 走下方
        # 的特殊处理, 不在数值表里
        if name in ("worker_pages", "factory_page", "factory_mode",
                    "retry_statuses"):
            continue
        spec = CONFIG_FIELDS.get(name)
        if not spec:
            skipped[name] = "unknown field"; continue
        lo, hi, setter = spec
        try:
            v = int(value)
        except (TypeError, ValueError):
            skipped[name] = f"not int: {value!r}"; continue
        if v < lo or (hi is not None and v > hi):
            skipped[name] = f"out of range [lo={lo}, hi={'∞' if hi is None else hi}]"; continue
        setter(v)
        applied[name] = v
    # retry_statuses 是唯一的集合型热改字段
    if "retry_statuses" in body:
        if pool.set_retry_statuses(body["retry_statuses"]):
            applied["retry_statuses"] = ",".join(map(str, sorted(pool.retry_statuses)))
        else:
            skipped["retry_statuses"] = "no valid status codes"
    # worker_pages 改了则触发一次扩缩容(经 /api/pool/scale 同一路径)
    if "worker_pages" in body:
        try:
            await pool.scale(int(body["worker_pages"]))
            applied["worker_pages"] = pool.desired_pages
        except Exception as e:
            skipped["worker_pages"] = str(e)
    # factory_page 是唯一的字符串型热改字段: 切换后工人页按新页重建
    if "factory_page" in body:
        try:
            r = await pool.switch_factory_page(body["factory_page"])
            applied["factory_page"] = r["factory_page"]
        except Exception as e:
            skipped["factory_page"] = str(e)
    # factory_mode: 工厂页承载方式热切(intercept/mock/real)
    if "factory_mode" in body:
        try:
            r = await pool.switch_factory_mode(body["factory_mode"])
            applied["factory_mode"] = r["factory_mode"]
        except Exception as e:
            skipped["factory_mode"] = str(e)
    return {"applied": applied, "skipped": skipped, "now": config_snapshot()}


# ---------- 鉴权 key 管理(热加/删, 需 ADMIN_KEY) ----------
@app.get("/api/keys")
async def api_keys_get(_admin=Depends(check_admin)):
    return {"keys": sorted(pool.relay_keys)}


@app.put("/api/keys")
async def api_keys_put(request: Request, _admin=Depends(check_admin)):
    """body: {"add":[...],"remove":[...]} 热改 relay key 集合。需 ADMIN_KEY。
    ⚠️ 删掉当前正在用的 key 会立即让本次会话之后的请求失效——别把自己锁门外。"""
    body = await request.json()
    add = set(body.get("add") or [])
    rm = set(body.get("remove") or [])
    if not add and not rm:
        return {"keys": sorted(pool.relay_keys), "note": "no changes"}
    before = set(pool.relay_keys)
    pool.relay_keys = (before | add) - rm
    return {"keys": sorted(pool.relay_keys),
            "added": sorted(add & pool.relay_keys),
            "removed": sorted(rm & before)}


@app.post("/api/test/chat")
async def api_test_chat(request: Request, _admin=Depends(check_admin)):
    """面板测试对话(需 ADMIN_KEY): 走与 /v1/chat/completions 相同的路径, 返回耗时拆解。"""
    body = await request.json()
    model = body.get("model") or "moonshotai/Kimi-K3"
    prompt = body.get("prompt") or "回一个 pong"
    ensure_ready()
    payload = {"model": model,
               "messages": [{"role": "user", "content": prompt}],
               "stream": True,
               "stream_options": {"include_usage": True},
               "max_tokens": int(body.get("max_tokens", 200))}
    t0 = time.monotonic()
    t_token, resp, status = None, None, 503
    try:
        for attempt in range(1 + pool.max_retry):
            token = await pool.issue()
            t_token = round(time.monotonic() - t0, 2)   # 等 token 的耗时
            resp = await upstream_client.send(
                upstream_client.build_request(
                    "POST", f"{UPSTREAM}/v1/openai/chat/completions",
                    headers=auth_headers(token), json=payload),
                stream=True)
            status = resp.status_code
            if status not in pool.retry_statuses:
                break
            await resp.aclose()
            await asyncio.sleep(_retry_backoff(attempt))
    except Exception as e:
        return {"ok": False, "stage": "token", "error": str(e)[:200]}

    t_up0 = time.monotonic()
    if status != 200:
        err = (await resp.aread()).decode("utf-8", "replace")[:300]
        await resp.aclose()
        return {"ok": False, "stage": "upstream", "status": status,
                "error": err, "t_token_s": t_token}
    text = []
    usage = None
    async for line in resp.aiter_lines():
        if line.startswith("data:"):
            data = line[5:].strip()
            if data == "[DONE]":
                break
            try:
                c = json.loads(data)
                for ch in c.get("choices", []):
                    d = ch.get("delta", {})
                    if d.get("content"):
                        text.append(d["content"])
                usage = c.get("usage") or usage
            except Exception:
                pass
    await resp.aclose()
    t_up = round(time.monotonic() - t_up0, 2)
    return {"ok": True, "model": model, "content": "".join(text)[:500],
            "usage": usage, "t_token_s": t_token, "t_upstream_s": t_up,
            "t_total_s": round(time.monotonic() - t0, 2)}