File size: 69,163 Bytes
7aca0bc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The intercept server.

    input   an OpenAI-spec endpoint you already host (vLLM or SGLang) + the served model name
    output  per rollout, a JSON document of exact token ids, logprobs and loss masks, ready to train

In between: a coding agent points at this URL, in whichever wire dialect it speaks, and
nothing about the agent changes except a base URL and an API key.

    agent (in an E2B sandbox, any of ~37)
       |  OPENAI_BASE_URL / ANTHROPIC_BASE_URL / provider config = this server
       |  API key = the rollout's session id       <- the entire multiplexing scheme
       v
    THIS  --detect dialect--> normalise to chat --inject capture params--> your engine
       <--replay in the agent's dialect (SSE if it asked for SSE)---------┘
       |
       └─ each call becomes a node in the rollout graph, linked by token prefix

WHY IT CAPTURES FAITHFULLY: we never tokenize. The engine tokenizes each prompt to serve it and hands
back `prompt_token_ids`, so turn k+1's prompt is the canonical tokenization of everything up to that
point, tool results included. Completions come back as sampled ids with aligned logprobs. Stitching
those along a graph path reproduces exactly what the model saw and produced, with no local chat
template involved. See `graph.py`.

TWO ASYMMETRIES, both learned from real failures:

  * **Capture is non-streaming, the reply is whatever the client asked for.** One complete response
    carries ids and logprobs whole; reassembling them from SSE deltas is error-prone in exactly the
    way that silently corrupts training data. But a harness that requested SSE and receives a JSON
    body does not error, it yields nothing: opencode reported `step-finish reason:"unknown"`, zero
    tokens, no error, having been handed a perfectly valid tool call. See `sse.py`.
  * **We validate on ingest, not on export.** A turn whose logprobs are misaligned must be caught
    while we still know which turn it was.

Run:  python -m intercept.server --llm-url http://127.0.0.1:8000 --model Qwen3.5-9B
"""

from __future__ import annotations

import argparse
import asyncio
import logging
import os
import secrets
import threading
import uuid
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import replace
from typing import Any

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse

from . import sse
from .contract import BUDGET_STOP_MESSAGE, to_trace_entries
from .detection import APIType, detect
from .dialects import TransformManager
from .export import export_session
from .graph import TurnNode
from .sessions import (
    evaluation_sampling,
    extract_api_key,
    extract_harness_session,
    rollout_type_for,
    SessionRegistry,
    Upstream,
)
from .upstream import (
    InferenceClient,
    training_sampling,
    truncating_params,
    UpstreamError,
    UpstreamHTTPError,
    UpstreamRequestError,
)
from .validate import check_turn, check_turn_eval

logger = logging.getLogger("intercept")


def _system_digest(messages: list[dict[str, Any]]) -> str | None:
    """Cheap identity for 'which conversation is this'. Recorded, never used for routing."""
    import hashlib

    for message in messages or []:
        if message.get("role") == "system":
            content = message.get("content")
            if isinstance(content, list):  # anthropic / responses send block lists
                content = " ".join(
                    p.get("text", "") for p in content if isinstance(p, dict)
                )
            if isinstance(content, str) and content:
                return hashlib.sha256(content.encode()).hexdigest()[:16]
    return None


# Routes an agent calls that are NOT model turns. They must be answered, but must never become graph
# nodes: recording them adds a bogus root and corrupts the trajectory structure.
#
# Borrowed from verifiers, whose Dialect ABC carries `aux_routes` for exactly this
# (v1/dialects/anthropic.py:273, "relayed as native JSON, never recorded on the trace").
# claude-code calls count_tokens before sending a turn; without this the catch-all would hand it to
# `transform_request`, forward nonsense upstream, and file the result as a model call.
# Each entry maps a route suffix to the DIALECT whose reply shape the caller expects. Answering an
# aux route in the wrong shape is its own bug: gemini-cli's `:countTokens` used to fall through to the
# catch-all, get detected as GOOGLE, turn into a real chat completion, land in the graph as a bogus
# root that inflated n_turns, and hand the caller a `{"candidates": [...]}` envelope where it wanted
# `{"totalTokens": N}`.
AUX_ROUTES: dict[str, str] = {
    "/v1/messages/count_tokens": "anthropic",
    ":counttokens": "google",
}


def aux_dialect(path: str) -> str | None:
    """Which dialect's token-count reply this path expects, or `None` if it is not an aux route.

    Google puts the method after a colon on the model path
    (`/v1beta/models/gemini-2.5-pro:countTokens`), so suffix matching has to be case-insensitive
    rather than an exact path compare.
    """
    normalised = ("/" + path.lstrip("/")).lower()
    for route, dialect in AUX_ROUTES.items():
        if normalised.endswith(route):
            return dialect
    return None


def is_aux_route(path: str) -> bool:
    return aux_dialect(path) is not None


def aux_token_count_response(dialect: str, count: int) -> dict[str, Any]:
    """The token-count reply in the shape the calling dialect expects."""
    if dialect == "google":
        # gemini-cli reads `totalTokens`; the other two fields are part of the documented response and
        # cheap to include rather than have a client guess at their absence.
        return {
            "totalTokens": count,
            "totalBillableCharacters": count * 4,
            "promptTokensDetails": [{"modality": "TEXT", "tokenCount": count}],
        }
    return {"input_tokens": count}


def approximate_token_count(body: dict[str, Any]) -> int:
    """Answer a count_tokens request without a tokenizer.

    We deliberately do not load one: the whole design keeps tokenization on the engine, and pulling a
    tokenizer in here just to serve a side request would reintroduce the "two sources of truth"
    problem this architecture exists to avoid. Agents use this figure for context-budget decisions,
    not for anything that reaches training, so a ~4-chars-per-token estimate is sufficient. If a
    harness turns out to depend on exactness, forward it to the engine's /tokenize endpoint instead.
    """
    text_len = 0
    for message in body.get("messages") or []:
        content = message.get("content")
        if isinstance(content, str):
            text_len += len(content)
        elif isinstance(content, list):
            for part in content:
                if isinstance(part, dict):
                    text_len += len(str(part.get("text") or part.get("content") or ""))
    system = body.get("system")
    if isinstance(system, str):
        text_len += len(system)
    elif isinstance(system, list):
        text_len += sum(
            len(str(p.get("text", ""))) for p in system if isinstance(p, dict)
        )

    # Google puts the conversation in `contents` with `parts`, and the system prompt in
    # `systemInstruction` — neither of which the OpenAI/Anthropic branches above look at. Without this
    # a `:countTokens` call collapsed to the `max(1, ...)` floor and answered 1 on every request, so
    # gemini-cli got a useless context-budget signal and could mismanage compaction mid-rollout. The
    # aux route answers Google now, so the estimator has to speak Google too.
    for content in body.get("contents") or []:
        if not isinstance(content, dict):
            continue
        for part in content.get("parts") or []:
            if isinstance(part, dict):
                text_len += len(str(part.get("text") or ""))
            elif isinstance(part, str):
                text_len += len(part)
    instruction = body.get("systemInstruction") or body.get("system_instruction")
    if isinstance(instruction, dict):
        for part in instruction.get("parts") or []:
            if isinstance(part, dict):
                text_len += len(str(part.get("text") or ""))
    elif isinstance(instruction, str):
        text_len += len(instruction)

    # Tool manifests count too, under each dialect's own spelling.
    text_len += len(str(body.get("tools") or ""))
    return max(1, text_len // 4)


def wants_stream(path: str, body: dict[str, Any]) -> bool:
    """Did the client ask for SSE? Each dialect says so differently.

    OpenAI chat, Responses and Anthropic all set `stream: true` in the body. **Google does not.** It
    signals streaming in the URL: `:streamGenerateContent`, usually with `?alt=sse`. gemini-cli calls

        POST /v1beta/models/<model>:streamGenerateContent?alt=sse

    with no `stream` key anywhere in the body, so a body-only check returns False and we answer a
    streaming request with a plain JSON document. It arrives as HTTP 200 and the client dies parsing
    it:

        Error: Incomplete JSON segment at the end
            at ApiClient.processStreamResponse_1 (@google/gemini-cli/...)

    Same failure family as opencode's silent `reason:"unknown"`: a valid-looking response in the
    wrong envelope. verifiers models this as a per-dialect `Dialect.streaming(body)`; this is the
    same idea kept to one function.
    """
    if body.get("stream") is True:
        return True
    lowered = path.lower()
    return "streamgeneratecontent" in lowered or "alt=sse" in lowered


_MAX_TOKENS_KEYS = ("max_tokens", "max_completion_tokens", "max_output_tokens")

# Sampling knobs that alter the distribution a processed logprob is taken over. Recorded per turn so a
# trainer can tell whether its own recompute is comparable; see `TurnNode.sampling_params`.
SAMPLING_KEYS = (
    "temperature",
    "top_p",
    "top_k",
    "min_p",
    "frequency_penalty",
    "presence_penalty",
    "repetition_penalty",
)


def clamp_output_tokens(chat_request: dict[str, Any], cap: int | None) -> int | None:
    """Cap the requested output length so prompt + completion fits the served context window.

    Harnesses ask for absurd output budgets. qwen-coder requests **64000** output tokens, which on a
    65536-token model leaves room for a 1536-token prompt and then fails on the next character:

        maximum context length is 65536 tokens. However, you requested 64000 output tokens and
        your prompt contains at least 1537 input tokens, for a total of at least 65537

    Every call 502s, the agent does nothing, and it presents as "reached the intercept, captured
    nothing". Polar caps this too (`proxy_max_tokens_cap = 16384`, noting opencode's ~32000 default
    "exceeds some provider limits"), so it is a known hazard rather than one harness misbehaving.

    A fixed cap rather than `context - len(prompt)`: computing the latter needs a tokenizer here, and
    keeping tokenization on the engine is the whole design. Agent turns are short (the longest seen
    across every validated harness is 874 tokens), so a few thousand is generous.

    Returns the value it replaced, for logging, or None if nothing changed.
    """
    if not cap:
        return None
    replaced = None
    for key in _MAX_TOKENS_KEYS:
        value = chat_request.get(key)
        if isinstance(value, int) and value > cap:
            chat_request[key] = cap
            replaced = max(replaced or 0, value)
    if not any(chat_request.get(key) is not None for key in _MAX_TOKENS_KEYS):
        chat_request["max_tokens"] = cap
    return replaced


def normalise_for_capture(chat_request: dict[str, Any]) -> None:
    """Force the upstream call into the one shape that yields complete, capturable responses.

    `stream_options` is not cosmetic: vLLM validates it against `stream` and rejects the pair with
    "Stream options can only be defined when `stream=True`", which 400s the ENTIRE request. opencode
    sends it on every call, so leaving it in is a total outage rather than a degradation.

    Both keys are set here rather than left to the inference client, because the client rewrites
    `stream` only after this point: a check against the incoming value sees `true` and leaves
    `stream_options` behind, which is precisely the bug this exists to prevent.

    An empty `tools` array is dropped for the same reason. vLLM rejects it outright:

        `tools` must not be an empty array. Either provide at least one tool or omit the field
        entirely.

    kimi-cli sends `tools: []` once its agent loop has no tools left to offer, which 400s the call.
    The two forms mean the same thing to the model, so dropping the key is lossless and keeps the
    rollout alive rather than truncating it mid-trajectory.

    Everything that only makes sense ALONGSIDE `tools` has to go with it. Dropping the list and
    leaving its companions behind trades one provider's 400 for another's:

        Invalid value for 'parallel_tool_calls': 'parallel_tool_calls' is only allowed when
        'tools' are specified.

    That is codex against OpenAI, every call, for the whole rollout — it sends `tools: []` plus
    `parallel_tool_calls`, so stripping only the list left the orphan behind. vLLM ignores the orphan,
    which is why this survived until a hosted provider was tried.
    """
    chat_request["stream"] = False
    chat_request.pop("stream_options", None)
    for key in ("tools", "functions"):
        if key in chat_request and not chat_request[key]:
            chat_request.pop(key)
    # `tool_choice` and `parallel_tool_calls` are equally invalid without `tools`, and meaningless
    # once the list is gone.
    if "tools" not in chat_request:
        chat_request.pop("tool_choice", None)
        chat_request.pop("parallel_tool_calls", None)


def normalise_response(response: dict[str, Any]) -> None:
    """Fill in usage sub-objects that vLLM leaves null but the OpenAI schema always returns.

    vLLM returns `"prompt_tokens_details": null` when prefix caching is off. OpenAI always returns
    the object, so a harness that reads `usage.prompt_tokens_details.cached_tokens` without guarding
    gets an AttributeError. trae-agent does exactly that and dies after its FIRST call:

        'NoneType' object has no attribute 'cached_tokens'

    which produced a clean single-turn capture and a task the agent never attempted.

    This touches ONLY accounting fields. No token id, logprob or message content is altered, so it
    cannot affect what gets captured or trained. It is a compatibility shim that makes us MORE
    OpenAI-conformant than the engine behind us, which is the safe direction: a client that already
    guarded for null sees a zeroed object instead, which reads the same.
    """
    usage = response.get("usage")
    if not isinstance(usage, dict):
        return
    if usage.get("prompt_tokens_details") is None:
        usage["prompt_tokens_details"] = {"cached_tokens": 0, "audio_tokens": 0}
    if usage.get("completion_tokens_details") is None:
        usage["completion_tokens_details"] = {
            "reasoning_tokens": 0,
            "audio_tokens": 0,
            "accepted_prediction_tokens": 0,
            "rejected_prediction_tokens": 0,
        }


def normalise_client_payload(payload: dict[str, Any], api_type: APIType) -> None:
    """Fill in usage sub-objects the OUTBOUND dialect promises but the transformer omits.

    Sibling of `normalise_response`, one layer further out. That one repairs the chat-completions
    usage we get FROM vLLM; this repairs the usage we hand TO the client after translation.

    Polar's Responses transformer builds usage as exactly
    `{"input_tokens", "output_tokens", "total_tokens"}` (transform/openai_responses.py:43), with no
    detail sub-objects. The real Responses API always returns them, and trae-agent reads them without
    a guard (trae_agent/utils/llm_clients/openai_client.py):

        cache_read_input_tokens=response.usage.input_tokens_details.cached_tokens or 0,
        reasoning_tokens=response.usage.output_tokens_details.reasoning_tokens or 0,

    so `input_tokens_details` is None and it dies with
    `'NoneType' object has no attribute 'cached_tokens'` after its FIRST call.

    Note this is why trae-agent looked like a chat-completions harness for a whole night: its seam
    says `openai_chat`, but the access log shows exactly one `POST /v1/responses` against 465
    chat-completions calls. It speaks Responses.

    Accounting fields only. No token id, logprob or content is touched, so capture is unaffected.
    """
    usage = payload.get("usage")
    if not isinstance(usage, dict):
        return
    if api_type is APIType.OPENAI_RESPONSES:
        if usage.get("input_tokens_details") is None:
            usage["input_tokens_details"] = {"cached_tokens": 0}
        if usage.get("output_tokens_details") is None:
            usage["output_tokens_details"] = {"reasoning_tokens": 0}


class UpstreamPool:
    """One inference client and one capability probe per distinct engine.

    The engine a rollout talks to is a per-SESSION property, not a per-server one: a dataset server is
    long-lived (thousands of task files, prebuilt sandbox templates) while a vLLM restarts every
    training run, and a train-tier engine and an eval-tier one are usually both wanted at once. So
    callers name their engine when they mint a session, and this pool makes that cheap.

    Two things it exists to avoid:

      * **Re-probing.** Deciding a tier means sending real completions (`validate_llm`). Doing that per
        session would add several round trips to every rollout, so the measurement is cached per
        `(url, model, auth_header, credential_digest)` and shared by matching sessions.
      * **Client churn.** One `InferenceClient` per engine rather than per rollout, so connection
        pooling and the discovered `param_fixes` are shared.

    The tier is MEASURED, never assumed: an engine that cannot be probed is `text`, the weakest, so a
    rollout is never stamped trainable without evidence.
    """

    _probes = ThreadPoolExecutor(max_workers=4, thread_name_prefix="capture-probe")

    def __init__(self, *, default_client: InferenceClient, default_level: str) -> None:
        self._default = (default_client, default_level)
        self._by_engine: dict[
            tuple[str, str, str, str, str], tuple[InferenceClient, str]
        ] = {}
        self._pending: dict[tuple[str, str, str, str, str], Future] = {}
        self._lock = threading.Lock()

    @property
    def default(self) -> tuple[InferenceClient, str]:
        """The engine this server was booted with, for sessions that name none."""
        return self._default

    def known(self) -> list[dict[str, Any]]:
        """What has been probed so far, for `/health`. Never includes credentials."""
        with self._lock:
            return [
                {
                    "llm_url": url,
                    # The client's model, not the key's: a caller may have left it blank for a
                    # single-model endpoint and the probe resolved it, so the key holds "" while the
                    # requests actually being sent carry the real name.
                    "model": client.served_model or "",
                    "capture_level": level,
                }
                for (url, _requested, _header, _credential, _provider), (
                    client,
                    level,
                ) in self._by_engine.items()
            ]

    def forget(self, upstream: Upstream) -> None:
        """Drop the cached client for `upstream`, and with it the credential it holds.

        Every proxied call resolves its session's engine here, so the next call naming this engine
        probes it again. Call it only once no session uses the engine: for a caller whose key should
        not outlive their use of it.
        """
        with self._lock:
            self._by_engine.pop(upstream.cache_key, None)

    async def resolve(self, upstream: Upstream) -> tuple[InferenceClient, str]:
        """Client and measured capture level for `upstream`, probing once per engine."""
        key = upstream.cache_key
        with self._lock:
            hit = self._by_engine.get(key)
            if hit is not None:
                return hit
            pending = self._pending.get(key)
            if pending is None:
                pending = self._probes.submit(self._resolve_once, upstream)
                self._pending[key] = pending
        # Shield shared work: cancelling one waiter must not cancel another rollout's probe.
        return await asyncio.shield(asyncio.wrap_future(pending))

    def _resolve_once(self, upstream: Upstream) -> tuple[InferenceClient, str]:
        key = upstream.cache_key
        try:
            model, level = self._probe(upstream)
            client = InferenceClient(
                base_url=upstream.llm_url.rstrip("/"),
                served_model=model,
                api_key=upstream.api_key,
                auth_header=upstream.auth_header,
                capture_level=level,
                provider=upstream.provider,
            )
            with self._lock:
                self._by_engine[key] = (client, level)
            return client, level
        finally:
            with self._lock:
                self._pending.pop(key, None)

    def _probe(self, upstream: Upstream) -> tuple[str, str]:
        """`(served_model, capture_level)`. Blocking; always called on a worker thread."""
        from .validate_llm import list_models, validate_llm

        model = upstream.model
        try:
            if not model:
                served = list_models(
                    upstream.llm_url,
                    api_key=upstream.api_key,
                    auth_header=upstream.auth_header,
                )
                # Only when unambiguous. Guessing here makes the proxy rewrite `model` to the wrong
                # name, and every call then fails upstream for a reason that mentions neither.
                model = served[0] if len(served) == 1 else ""
            if not model:
                logger.warning(
                    "upstream %s serves several models and none was named; capture level is 'text'",
                    upstream.llm_url,
                )
                return "", "text"
            report = validate_llm(
                upstream.llm_url,
                model,
                api_key=upstream.api_key,
                auth_header=upstream.auth_header,
                provider=upstream.provider,
            )
            level = report.capture_level or "text"
            logger.warning(
                "probed upstream %s (%s): capture_level=%s rollout_type=%s",
                upstream.llm_url,
                model,
                level,
                report.rollout_type,
            )
            return model, level
        except Exception as exc:  # noqa: BLE001 - an unreachable engine is a tier, not a crash
            logger.warning(
                "could not probe upstream %s: %s: %s; capture level is 'text'",
                upstream.llm_url,
                type(exc).__name__,
                exc,
            )
            return model, "text"


def _budget_stop_response(model: str) -> dict[str, Any]:
    """A terminal chat-completion, in the shape every dialect transformer expects on the way out.

    The content is NON-EMPTY on purpose. An empty assistant message reads as a failed generation and
    the harness retries it, so the proxy answers the stop again and the rollout spins until its
    timeout -- measured, with opencode looping on exactly this. It is deliberately a statement about
    the BUDGET rather than about the task: it lands in the harness's own transcript, and capture never
    records it, so it must not look like something the model chose to say about the work.
    """
    return {
        "id": f"budget-{uuid.uuid4().hex[:12]}",
        "object": "chat.completion",
        "model": model,
        "choices": [
            {
                "index": 0,
                "message": {
                    "role": "assistant",
                    "content": BUDGET_STOP_MESSAGE,
                    "tool_calls": None,
                },
                "finish_reason": "stop",
            }
        ],
        "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
    }


def create_app(
    *,
    llm_url: str = "",
    model: str | None = None,
    engine: str = "",
    require_registered: bool = True,
    max_output_tokens: int | None = 8192,
    api_key: str | None = None,
    auth_header: str = "Authorization",
    provider: str = "openai",
    capture_level: str = "tokens",
    admin_key: str | None = None,
    max_model_calls: int = 0,
) -> FastAPI:
    """The capture proxy as an ASGI app.

    Args:
        llm_url (`str`):
            The OpenAI-spec endpoint to forward to.
        model (`str`, *optional*):
            Served model id to send upstream, overriding whatever the agent asked for.
        engine (`str`, *optional*):
            What is on the other end, for `/health` only. Defaults to `"unknown"` there rather than
            to `"vllm"`: the proxy cannot tell, nothing passes it on the harbor path, and a hosted
            endpoint reported as vLLM reads as a claim about capture that `capture_level` then
            contradicts.
        require_registered (`bool`, *optional*, defaults to `True`):
            Reject callers whose API key is not a minted session id. This port may be public.
        max_output_tokens (`int`, *optional*, defaults to `8192`):
            Cap on requested completion length; `0` or `None` disables.
        api_key (`str`, *optional*):
            Credential for the *upstream*. Never the agent-facing key — that one is the session id,
            and the sandbox never sees this value.
        auth_header (`str`, *optional*, defaults to `"Authorization"`):
            Header to send `api_key` under.
        capture_level (`str`, *optional*, defaults to `"tokens"`):
            What the upstream can return, as decided by `validate_llm`. Below `tokens` every rollout
            from this app is an eval rollout.
        admin_key (`str`, *optional*):
            Required by the session-management routes when set. Leave unset for a private port; set it
            whenever this app is reachable from outside, which `serve` and `rollout` do automatically.
        max_model_calls (`int`, *optional*, defaults to `0`):
            Default ceiling on model calls per session; `0` is unlimited, and a session may name its
            own. Once a rollout reaches it the proxy answers a terminal completion itself, which ends
            the agent's loop without recording a turn the model never generated. Worth setting on any
            training deployment: no agent harness honours its own step config (opencode 1.18.30 ran 61
            model calls at `steps=3`, `maxSteps=3` and unset alike), and one runaway rollout holds its
            whole GRPO group hostage.
    """
    if provider == "anthropic":
        capture_level = "text"
    app = FastAPI(title="openenv-capture")

    # Identifies this app instance on /health. A caller that binds a port cannot tell "my server is
    # up" from "someone else's server already held this port" by connecting alone, and answering the
    # wrong process is silent: sessions are minted here and rejected there, so the agent gets 401 and
    # the rollout reports no model calls.
    app.state.instance_id = uuid.uuid4().hex
    # `None` when the server was booted without an engine. That is a supported state, not a broken
    # one: the datasets are what makes this server worth keeping alive, and the engine is a per-rollout
    # detail that arrives with the session. A session that names no engine and finds no default gets a
    # clear 503 rather than calls to an empty base URL.
    app.state.inference = (
        InferenceClient(
            base_url=llm_url.rstrip("/"),
            served_model=model,
            api_key=api_key,
            auth_header=auth_header,
            capture_level=capture_level,
            provider=provider,
        )
        if llm_url
        else None
    )
    app.state.transforms = TransformManager()
    app.state.registry = SessionRegistry(require_registered=require_registered)
    app.state.model = model
    app.state.max_model_calls = max_model_calls
    app.state.llm_url = llm_url
    app.state.max_output_tokens = max_output_tokens
    app.state.capture_level = capture_level
    # Per-session engines. The client built above is the DEFAULT, used by sessions that name none, so
    # a server booted with --llm-url behaves exactly as before.
    app.state.upstreams = UpstreamPool(
        default_client=app.state.inference, default_level=capture_level
    )
    app.state.admin_key = admin_key or None

    async def _upstream_for(session) -> tuple[InferenceClient, str]:
        """The engine this session's calls go to, and the level it was measured at."""
        if session.upstream is not None:
            return await app.state.upstreams.resolve(session.upstream)
        return app.state.upstreams.default

    def _model_of(session) -> str:
        """The model name to send upstream for this session.

        The proxy rewrites `model` because harnesses mangle it: opencode is configured with
        `intercepted/<model>` and the provider layer forwards only the last path segment, so what
        arrives is `Qwen3.5-2B` where the engine serves `Qwen/Qwen3.5-2B` and answers 404. Rewriting
        it is therefore not a nicety, it is what makes the call work at all.

        With the engine per session, the name has to come from the SESSION's engine. Reading the
        server default here is what broke an engineless server: `app.state.model` was empty, the
        rewrite was skipped, and the agent's mangled name went upstream untouched.
        """
        if (
            session is not None
            and session.upstream is not None
            and session.upstream.model
        ):
            return session.upstream.model
        return app.state.model or ""

    def _level_of(session) -> str:
        """A session's capture level without touching the network.

        Reads what the probe recorded on the session, falling back to the server default. Used where
        the level is needed but no upstream call is being made — exporting, for instance.
        """
        return session.capture_level or app.state.capture_level

    @app.get("/health")
    async def health(request: Request) -> dict[str, Any]:
        return {
            "status": "ok",
            "instance": app.state.instance_id,
            "upstream": llm_url,
            "engine": engine or "unknown",
            "model": app.state.model,
            "sessions": len(app.state.registry.list_ids()),
            "require_registered": app.state.registry.require_registered,
            # What this proxy can actually produce, so a caller never has to infer it from the
            # engine name. `upstream_auth` is a boolean on purpose: the key itself must not be
            # readable from an endpoint that, on a Space, is public.
            "capture_level": app.state.capture_level,
            "rollout_type": "train" if app.state.capture_level == "tokens" else "eval",
            # Engines named per session and already probed, so a caller can see what this server
            # measured without minting a session. Behind the admin key when one is set: on a public
            # Space these are other callers' endpoints, private tunnel URLs among them.
            "upstreams": app.state.upstreams.known() if _admin_ok(request) else [],
            "upstream_auth": bool(app.state.inference and app.state.inference.api_key),
            "param_fixes": (
                [str(f) for f in app.state.inference.param_fixes]
                if app.state.inference
                else []
            ),
        }

    def _admin_ok(request: Request) -> bool:
        """Whether a caller may use the session-management routes.

        The registered-session check guards the catch-all proxy route and nothing else, which is fine
        while this app owns a private port and wrong once it is mounted at `/capture` on a public
        Space: `GET /sessions` enumerated every live rollout, `GET /sessions/{id}/rollout` returned its
        full token-level training data, `DELETE` ended it, and `POST /sessions` let anyone mint a key
        that the proxy would then honour — turning the endpoint into the open relay the 401 exists to
        prevent.

        Gated on the ADMIN key rather than a session id, because these routes are the trainer's
        control plane, not the agent's data plane. `admin_key` defaults to unset, which keeps a
        local run on a private port exactly as convenient as before; `serve`/`push` set it whenever
        the proxy is reachable from outside.
        """
        expected = app.state.admin_key
        if not expected:
            return True
        offered = extract_api_key(dict(request.headers)) or ""
        # Constant-time: these are short strings and the comparison is cheap, but a timing oracle on a
        # public endpoint is free to exploit and free to close.
        return secrets.compare_digest(offered, expected)

    def _forbidden() -> JSONResponse:
        return JSONResponse(
            {
                "error": {
                    "message": "this route requires the capture admin key",
                    "type": "invalid_request_error",
                }
            },
            status_code=401,
        )

    def _invalid_request(message: str) -> JSONResponse:
        return JSONResponse(
            {"error": {"message": message, "type": "invalid_request_error"}},
            status_code=400,
        )

    @app.post("/sessions")
    async def create_session(
        request: Request, payload: dict[str, Any] | None = None
    ) -> Any:
        """Mint a rollout id. Hand it to the agent as its API key; that is the whole integration.

        The caller may also name the engine this rollout should use — `llm_url`, and optionally
        `model`, `api_key`, `auth_header`. That engine is PROBED HERE, before the session is handed
        back, and the measured tier is returned with it. So a caller learns whether it is going to get
        a trainable rollout at submit time, not minutes later when the token fields come back empty.

        Omitting `llm_url` uses the server's default engine, which is what a server booted with
        `--llm-url` has always done.
        """
        if not _admin_ok(request):
            return _forbidden()
        payload = payload or {}
        metadata = payload.get("metadata")
        if metadata is not None and not isinstance(metadata, dict):
            return _invalid_request("metadata must be a JSON object")
        metadata = metadata or {}
        reserved = {
            "session_id",
            "upstream",
            "capture_level",
            "max_model_calls",
            "sampling",
            "purpose",
            "eval_sampling",
        } & metadata.keys()
        if reserved:
            noun = "key" if len(reserved) == 1 else "keys"
            return _invalid_request(
                f"metadata cannot include reserved {noun}: {', '.join(sorted(reserved))}"
            )
        budget = payload.get("max_model_calls", app.state.max_model_calls)
        if type(budget) is not int or budget < 0:
            return _invalid_request("max_model_calls must be a non-negative integer")
        try:
            policy = training_sampling(payload.get("sampling"))
        except ValueError as exc:
            return _invalid_request(str(exc))
        upstream = None
        level = ""
        llm_url = str(payload.get("llm_url") or "").strip()
        if llm_url:
            upstream = Upstream(
                llm_url=llm_url,
                model=str(payload.get("model") or "").strip(),
                api_key=payload.get("api_key") or None,
                auth_header=str(payload.get("auth_header") or "Authorization"),
                provider=str(payload.get("provider") or "openai"),
            )
            # Probe now. The cache means this costs round trips only for an engine never seen before,
            # so a whole GRPO group naming the same vLLM pays for it once.
            client, level = await app.state.upstreams.resolve(upstream)
            # Carry the model the probe settled on: the caller may have left it blank for a
            # single-model endpoint, and the proxy needs the resolved name to rewrite requests.
            upstream = replace(upstream, model=client.served_model or upstream.model)
        purpose = payload.get("purpose", "auto")
        try:
            rollout_type = rollout_type_for(purpose, level or app.state.capture_level)
            eval_policy = evaluation_sampling(payload.get("eval_sampling"))
            if eval_policy and purpose != "eval":
                raise ValueError("eval_sampling requires explicit eval purpose")
            if purpose == "eval" and payload.get("sampling") is not None:
                raise ValueError(
                    "eval purpose cannot apply a training sampling override"
                )
        except (ValueError, TypeError) as exc:
            return _invalid_request(str(exc))
        session = app.state.registry.create(
            payload.get("session_id"),
            purpose=purpose,
            eval_sampling=eval_policy,
            upstream=upstream,
            capture_level=level,
            # Falls back to the server default, so a deployment can cap every rollout without its
            # callers knowing, and a caller can still tighten it per rollout.
            max_model_calls=budget,
            sampling=policy or None,
            **metadata,
        )
        effective = _level_of(session)
        return {
            "session_id": session.session_id,
            "capture_level": effective,
            "rollout_type": rollout_type,
            "max_model_calls": session.max_model_calls,
            "llm_url": upstream.llm_url if upstream else app.state.llm_url,
            "model": upstream.model if upstream else app.state.model,
        }

    @app.get("/sessions")
    async def list_sessions(request: Request) -> Any:
        if not _admin_ok(request):
            return _forbidden()
        return {"sessions": app.state.registry.summary()}

    @app.get("/sessions/{session_id}")
    async def session_status(session_id: str, request: Request) -> Any:
        if not _admin_ok(request):
            return _forbidden()
        """Live progress. `idle_s` is the cheapest wedge detector: turns arriving means progress."""
        session = app.state.registry.get(session_id)
        if session is None:
            return JSONResponse({"error": "unknown session"}, status_code=404)
        return {
            "session_id": session_id,
            "idle_s": round(session.idle_seconds, 1),
            "upstream_errors": session.upstream_errors,
            **session.graph.stats(),
        }

    @app.get("/sessions/{session_id}/rollout")
    async def rollout(
        session_id: str,
        request: Request,
        include_discarded: bool = False,
        include_messages: bool = False,
    ) -> Any:
        """THE training endpoint: stitched, masked, logprob-aligned, validated."""
        if not _admin_ok(request):
            return _forbidden()
        session = app.state.registry.get(session_id)
        if session is None:
            return JSONResponse({"error": "unknown session"}, status_code=404)
        return export_session(
            session,
            include_discarded=include_discarded,
            include_messages=include_messages,
            capture_level=_level_of(session),
        )

    @app.get("/sessions/{session_id}/trace_entries")
    async def trace_entries(session_id: str, request: Request) -> Any:
        """The LOOP-OWNING training endpoint: `list[TraceEntry]`, one per captured model call.

        `/rollout` returns the stitched document (input_ids + loss_mask + logprobs) for a consumer
        that trains on whole sequences. A loop-owning consumer wants the per-call records instead --
        the agent drove its own tool loop, so the unit is "one model call" and it re-derives the
        prompt from `request`. That is what `LoopOwningSession.fetch_proxy_trace` is defined to
        return, and `to_trace_entries` already produced this shape; it just had no way to be asked,
        because it needs the session's graph and that is server-side state.

        Same registry as every other session here, so this serves a rollout from ANY harness --
        opencode, codex, claude-code, a Harbor task -- without knowing which produced it.
        """
        if not _admin_ok(request):
            return _forbidden()
        session = app.state.registry.get(session_id)
        if session is None:
            return JSONResponse({"error": "unknown session"}, status_code=404)
        # `to_trace_entries` refuses a non-trainable document rather than silently handing back
        # rows with empty token fields, which is the failure this whole contract exists to avoid.
        document = export_session(
            session, include_messages=True, capture_level=_level_of(session)
        )
        try:
            return {
                "session_id": session_id,
                "entries": to_trace_entries(session.graph, document),
            }
        except Exception as exc:
            return JSONResponse(
                {"error": f"{type(exc).__name__}: {exc}", "session_id": session_id},
                status_code=409,
            )

    @app.delete("/sessions/{session_id}")
    async def delete_session(session_id: str, request: Request) -> Any:
        if not _admin_ok(request):
            return _forbidden()
        return {"deleted": app.state.registry.delete(session_id)}

    @app.get("/v1/models")
    async def models() -> Any:
        """The default engine's model list.

        Deliberately not session-scoped: this route is unauthenticated and answers before any session
        exists, and some harnesses call it to decide whether their configured model is available. With
        no default engine there is nothing to list, and an empty list is the honest answer rather than
        a 500.
        """
        if app.state.inference is None:
            return {"object": "list", "data": []}
        return await app.state.inference.list_models()

    @app.post("/{path:path}")
    async def proxy(path: str, request: Request) -> Any:
        """Catch-all: /v1/chat/completions, /v1/messages, /v1/responses, :generateContent."""
        headers = dict(request.headers)
        try:
            body = await request.json()
        except Exception:  # noqa: BLE001
            return _invalid_request("body must be JSON")
        if not isinstance(body, dict):
            return _invalid_request("body must be a JSON object")

        # Answered, never recorded. Must come before session routing and dialect handling: an aux
        # route is not a model turn, so it has no business creating a node or a session.
        aux = aux_dialect(path)
        if aux is not None:
            logger.info("aux route %s (answered as %s, not recorded)", path, aux)
            return JSONResponse(
                aux_token_count_response(aux, approximate_token_count(body))
            )

        session = app.state.registry.resolve(headers, body)
        if session is None:
            # Deliberately 401 rather than serving an unknown caller: this port is public.
            return JSONResponse(
                {
                    "error": {
                        "message": "unknown API key; register a session via POST /sessions",
                        "type": "invalid_request_error",
                    }
                },
                status_code=401,
            )

        api_type: APIType = detect(f"/{path}", headers, body)
        transformer = app.state.transforms.get(api_type)

        import copy

        original_request = copy.deepcopy(body)
        # Include the query string: Google puts `alt=sse` there, not in the body.
        full_target = (
            f"/{path}?{request.url.query}" if request.url.query else f"/{path}"
        )
        client_wants_stream = wants_stream(full_target, body)

        # BUDGET CHECK, BEFORE ANYTHING IS FORWARDED OR RECORDED.
        #
        # Placed after `client_wants_stream` deliberately. Answering a STREAMING request with a plain
        # JSON body does not end the agent's loop -- opencode streams, and it simply retried, so the
        # proxy answered the stop over and over while the rollout burned its whole timeout. The reply
        # has to go back in the dialect the caller asked for, which is what `sse.replay` is for.
        #
        # The same experiment that showed opencode ignores its own `steps` setting also showed what
        # DOES end its loop: a plain assistant message with `finish_reason="stop"` and no tool calls
        # terminates cleanly and `opencode run` exits 0. The content must be non-empty -- an empty
        # assistant message reads as a failed generation and gets retried.
        #
        # Because this returns before `_ingest`, CAPTURE NEVER SEES IT, so no turn the model did not
        # generate can enter the training data. It bounds cost and wall clock and nothing else: it is
        # not a nudge and says nothing about the task, because shaping what an agent does with its
        # last turns is the environment's business, not every environment's.
        if session.over_budget:
            logger.info(
                "session %s hit its model-call budget (%d); ending the agent loop",
                session.session_id,
                session.max_model_calls,
            )
            session.budget_stop_count += 1
            stop = _budget_stop_response(_model_of(session) or app.state.model)
            if client_wants_stream:
                return StreamingResponse(
                    sse.replay(api_type, transformer, stop, original_request),
                    media_type="text/event-stream",
                    headers=sse.SSE_HEADERS,
                )
            payload = transformer.transform_response(stop, original_request)
            normalise_client_payload(payload, api_type)
            return JSONResponse(payload)
        # The served model name has to be on the body BEFORE the transformer runs: each dialect
        # reads `_served_model` inside `transform_request` to decide per-model request fixes, and
        # `BaseTransformer._normalize_request` strips it again on the way out. Setting it afterwards,
        # as the upstream client used to, meant the transformers never saw it (so the Qwen3.5
        # thinking fix silently never applied) and the marker travelled on to the engine unused.
        incoming = dict(body)
        served_model = _model_of(session)
        if served_model:
            incoming["_served_model"] = served_model
        try:
            chat_request = transformer.transform_request(incoming)
        except UpstreamRequestError as exc:
            return _invalid_request(str(exc))
        if chat_request.get("n", 1) != 1 or isinstance(chat_request.get("n"), bool):
            return _invalid_request(
                "capture supports exactly one completion per request (n=1)"
            )
        if served_model:
            chat_request["model"] = served_model
        normalise_for_capture(chat_request)
        requested_sampling = {
            key: chat_request[key] for key in SAMPLING_KEYS if key in chat_request
        }
        if getattr(session, "eval_sampling", None):
            chat_request.update(session.eval_sampling)
        if session.sampling:
            # These alter logits beyond the full-vocabulary, temperature-only policy the
            # trainer recomputes. Capture cannot reconstruct an unreported grammar mask.
            constrained = [
                key
                for key in (
                    "logit_bias",
                    "allowed_token_ids",
                    "structured_outputs",
                    "guided_json",
                    "guided_regex",
                    "guided_choice",
                    "guided_grammar",
                    "min_tokens",
                )
                if chat_request.get(key)
            ]
            response_format = chat_request.get("response_format") or {}
            if not isinstance(response_format, dict):
                return _invalid_request("response_format must be a JSON object")
            if response_format.get("type", "text") != "text":
                constrained.append("response_format")
            tool_choice = chat_request.get("tool_choice")
            if tool_choice is not None and tool_choice not in ("auto", "none"):
                constrained.append("tool_choice")
            if constrained:
                return _invalid_request(
                    "training sampling cannot reproduce constrained logits: "
                    + ", ".join(constrained)
                )
            chat_request.update(session.sampling)
        output_cap = session.metadata.get("max_output_tokens")
        if output_cap is not None and (type(output_cap) is not int or output_cap < 1):
            return _invalid_request(
                "session max_output_tokens must be a positive integer"
            )
        if app.state.max_output_tokens:
            output_cap = (
                min(output_cap, app.state.max_output_tokens)
                if output_cap
                else app.state.max_output_tokens
            )
        clamped = clamp_output_tokens(chat_request, output_cap)
        if clamped:
            logger.info(
                "clamped requested output tokens %d -> %d",
                clamped,
                output_cap,
            )

        upstream_client, session_level = await _upstream_for(session)
        if session.sampling and session_level != "tokens":
            return _invalid_request(
                "training sampling requires an endpoint with token capture"
            )
        if upstream_client is None:
            # No engine on the session and none on the server. Saying so beats forwarding to an empty
            # base URL, which surfaces as a connection error that names neither cause.
            return JSONResponse(
                {
                    "error": {
                        "message": "no inference engine for this session: name one as `llm_url` "
                        "when creating the session, or boot the server with --llm-url"
                    }
                },
                status_code=503,
            )
        # Reserve after validation and upstream resolution. No await separates the check and
        # increment, so concurrent requests on the capture loop cannot exceed the session budget.
        if session.over_budget:
            session.budget_stop_count += 1
            stop = _budget_stop_response(_model_of(session) or app.state.model)
            if client_wants_stream:
                return StreamingResponse(
                    sse.replay(api_type, transformer, stop, original_request),
                    media_type="text/event-stream",
                    headers=sse.SSE_HEADERS,
                )
            payload = transformer.transform_response(stop, original_request)
            normalise_client_payload(payload, api_type)
            return JSONResponse(payload)
        if (
            getattr(upstream_client, "provider", "openai") == "anthropic"
            and api_type == APIType.ANTHROPIC
        ):
            chat_request["_openenv_native_request"] = {
                **original_request,
                **getattr(session, "eval_sampling", {}),
            }
            chat_request["_openenv_native_headers"] = {
                key: request.headers[key]
                for key in ("anthropic-beta", "anthropic-version")
                if key in request.headers
            }
        session.model_calls += 1

        async def complete_response():
            try:
                response = await upstream_client.completion(chat_request)
            except UpstreamError as exc:
                # A known context limit ends the rollout's compute budget. Let the
                # harness terminate normally so Harbor can grade its current answer.
                # Like the model-call stop, this control response is never ingested.
                if isinstance(exc, UpstreamHTTPError) and exc.status_code == 400:
                    error = (
                        exc.body.get("error", exc.body)
                        if isinstance(exc.body, dict)
                        else {}
                    )
                    code = error.get("code") if isinstance(error, dict) else None
                    message = str(exc).lower()
                    if code == "context_length_exceeded" or (
                        "maximum context length" in message and "tokens" in message
                    ):
                        session.findings.append(
                            "[WARN] context_budget_exhausted: " + str(exc)
                        )
                        session.budget_stop_count += 1
                        stop = _budget_stop_response(
                            _model_of(session) or app.state.model
                        )
                        if client_wants_stream:
                            return StreamingResponse(
                                sse.replay(
                                    api_type, transformer, stop, original_request
                                ),
                                media_type="text/event-stream",
                                headers=sse.SSE_HEADERS,
                            )
                        payload = transformer.transform_response(stop, original_request)
                        normalise_client_payload(payload, api_type)
                        return JSONResponse(payload)
                session.upstream_errors += 1
                logger.warning("upstream error [%s]: %s", session.session_id, exc)
                # Preserve a permanent request error: converting a 400 (for example,
                # an image sent to a text-only engine) into 502 makes harnesses retry
                # unchanged input for minutes. Transient failures retain the gateway
                # response, and auth statuses are not exposed as harness credentials.
                from .providers import ProviderConversionError

                status = (
                    400
                    if isinstance(exc, ProviderConversionError)
                    else exc.status_code
                    if isinstance(exc, UpstreamHTTPError)
                    and exc.status_code in (400, 413, 422)
                    else 502
                )
                return JSONResponse(
                    {"error": {"message": str(exc)}}, status_code=status
                )

            native_response = response.pop("_openenv_native_response", None)
            effective_sampling = response.pop("_openenv_sampling", None)
            if session.sampling and (
                effective_sampling is None
                or any(
                    effective_sampling.get(key) != value
                    for key, value in session.sampling.items()
                )
            ):
                session.findings.append(
                    "[FATAL] sampling_policy_changed: upstream did not preserve the session training policy"
                )
                session.upstream_errors += 1
                return JSONResponse(
                    {
                        "error": {
                            "message": "upstream did not preserve the session training sampling policy"
                        }
                    },
                    status_code=502,
                )
            if effective_sampling is not None:
                for key in SAMPLING_KEYS:
                    chat_request.pop(key, None)
                chat_request.update(effective_sampling)
            normalise_response(response)
            _ingest(
                session,
                chat_request,
                response,
                api_type,
                session_level,
                requested_sampling,
            )

            if native_response is not None and api_type == APIType.ANTHROPIC:
                if client_wants_stream:
                    from .providers import replay_anthropic

                    return StreamingResponse(
                        replay_anthropic(native_response),
                        media_type="text/event-stream",
                        headers=sse.SSE_HEADERS,
                    )
                return JSONResponse(native_response)
            if client_wants_stream:
                return StreamingResponse(
                    sse.replay(api_type, transformer, response, original_request),
                    media_type="text/event-stream",
                    headers=sse.SSE_HEADERS,
                )
            payload = transformer.transform_response(response, original_request)
            normalise_client_payload(payload, api_type)
            return JSONResponse(payload)

        if client_wants_stream:
            return await sse.keepalive_response(complete_response(), api_type)
        return await complete_response()

    def _ingest(
        session,
        chat_request: dict[str, Any],
        response: dict[str, Any],
        api_type: APIType,
        capture_level: str,
        requested_sampling: dict[str, Any] | None = None,
    ) -> None:
        """Turn one upstream response into a graph node, validating before it lands.

        Never raises. A capture problem must degrade one turn, not kill a rollout that is otherwise
        producing usable data, and certainly not take down the server serving every other rollout.
        """
        try:
            choices = response.get("choices") or []
            if not choices:
                # A 200 with no choices is not a turn. Recording it produced a node with no prompt and
                # no completion, which inflated n_turns and n_roots and could push a worthless rollout
                # past the `degenerate_rollout` FATAL that exists to catch exactly that.
                logger.warning(
                    "[%s] upstream returned 200 with no choices; not recording a turn",
                    session.session_id,
                )
                session.upstream_errors += 1
                return
            choice = choices[0] or {}
            logprob_entries = (choice.get("logprobs") or {}).get("content") or []
            logprobs = [e.get("logprob") for e in logprob_entries] or None
            # A None inside the list passes the length check and then crashes export on `None > 0.0`,
            # turning `GET /sessions/{id}/rollout` into a 500. Missing values mean the turn cannot be
            # trained on, which is what dropping them to None already signals.
            if logprobs is not None and any(lp is None for lp in logprobs):
                logger.warning(
                    "[%s] %d of %d logprobs are null; treating the turn as untrainable",
                    session.session_id,
                    sum(1 for lp in logprobs if lp is None),
                    len(logprobs),
                )
                logprobs = None
            sampled_ids = choice.get("token_ids") or []
            prompt_ids = response.get("prompt_token_ids") or []
            index = session.graph.stats()["n_turns"]

            if capture_level != "tokens":
                # An eval turn. Grade what an eval turn can be graded on and keep going: running
                # `check_turn` here would report `no_prompt_ids` and `no_logprobs` as FATAL on every
                # single turn, which is true and useless — it is the known, accepted property of the
                # endpoint, decided before the server started.
                #
                # Any logprobs that did come back are kept on the node for the confidence readout,
                # but they are not a training signal: with no token ids there is nothing to align
                # them to, so `export` never promotes them to `per_token_logps`.
                report = check_turn_eval(
                    choice.get("message"),
                    finish_reason=choice.get("finish_reason"),
                    index=index,
                )
                session.findings.extend(str(f) for f in report.findings)
            else:
                report = check_turn(
                    prompt_ids,
                    sampled_ids,
                    logprobs,
                    finish_reason=choice.get("finish_reason"),
                    index=index,
                )
                session.findings.extend(str(f) for f in report.findings)
                if not report.ok:
                    logger.warning(
                        "[%s] turn %d rejected: %s",
                        session.session_id,
                        index,
                        "; ".join(str(f) for f in report.fatal),
                    )
                    # Still recorded, with logprobs dropped: the tokens are real context for later
                    # turns, and `sequence_for` masks a turn whose logprobs it cannot trust.
                    logprobs = None

            # A rewritten request is how an eval number becomes unreproducible, so say so — once per
            # session, since every turn of a given harness sends the same knobs.
            if capture_level == "tokens":
                overridden = truncating_params(requested_sampling or chat_request)
                if overridden and not session.metadata.get("sampling_overridden"):
                    session.metadata["sampling_overridden"] = overridden
                    session.findings.append(
                        "WARN sampling_neutralised: the harness asked for "
                        + ", ".join(f"{k}={v}" for k, v in sorted(overridden.items()))
                        + "; these were sent upstream at their no-op values because a processed "
                        "logprob is taken after they are applied, which would bias a full-vocab "
                        "recompute. requested_sampling_params preserves the harness request; "
                        "sampling_params records the submitted policy."
                    )

            session.graph.add_turn(
                TurnNode(
                    node_id=uuid.uuid4().hex[:12],
                    prompt_ids=list(prompt_ids),
                    sampled_ids=list(sampled_ids),
                    sampled_logprobs=list(logprobs) if logprobs else None,
                    model=chat_request.get("model"),
                    finish_reason=choice.get("finish_reason"),
                    harness_session_id=extract_harness_session({}, chat_request),
                    system_digest=_system_digest(chat_request.get("messages") or []),
                    n_tools=len(chat_request.get("tools") or []),
                    request_messages=chat_request.get("messages") or [],
                    request_tools=chat_request.get("tools"),
                    sampling_params={
                        key: chat_request[key]
                        for key in SAMPLING_KEYS
                        if chat_request.get(key) is not None
                    },
                    requested_sampling_params=requested_sampling or {},
                    response_message=choice.get("message") or {},
                )
            )
            session.last_turn_at = __import__("time").time()
            session.metadata.setdefault("api_type", api_type.value)
        except Exception:  # noqa: BLE001
            session.findings.append(
                "[FATAL] capture_ingest_failed: an upstream response could not be recorded"
            )
            logger.exception("[%s] ingest failed; turn dropped", session.session_id)

    return app


def _is_loopback_host(host: str) -> bool:
    """Whether `host` is a loopback-only bind (private local port).

    `0.0.0.0` / `::` are intentionally not loopback: they publish on every interface.
    """
    return host in {"127.0.0.1", "::1", "localhost"}


def main() -> None:
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument(
        "--llm-url", required=True, help="OpenAI-spec endpoint you host"
    )
    parser.add_argument(
        "--model", default=None, help="served model name to send upstream"
    )
    parser.add_argument(
        "--engine",
        default="",
        choices=["", "vllm", "sglang"],
        help="what is on the other end, for /health only. Left unset by default because the "
        "upstream may be a hosted provider, and reporting one of these two when it is not says "
        "something untrue about capture.",
    )
    parser.add_argument(
        "--host",
        default="127.0.0.1",
        help="bind address (default: 127.0.0.1). Non-loopback binds require --admin-key or "
        "$OPENENV_CAPTURE_ADMIN_KEY so /sessions* is never left open on a reachable interface.",
    )
    parser.add_argument("--port", type=int, default=8100)
    parser.add_argument(
        "--max-output-tokens",
        type=int,
        default=8192,
        help="cap on requested completion length; 0 disables",
    )
    parser.add_argument(
        "--allow-unregistered",
        action="store_true",
        help="serve unknown API keys (local debugging only; this port may be public)",
    )
    parser.add_argument(
        "--api-key",
        default=os.environ.get("OPENENV_LLM_API_KEY", ""),
        help="credential for the UPSTREAM endpoint (defaults to $OPENENV_LLM_API_KEY). Not the "
        "agent-facing key: that is a capture session id, minted per rollout.",
    )
    parser.add_argument(
        "--auth-header",
        default="Authorization",
        help="header to send --api-key under; `Authorization` gets a Bearer prefix, anything else "
        "(e.g. x-api-key) gets the raw key",
    )
    parser.add_argument(
        "--capture-level",
        default="",
        choices=["", "tokens", "logprobs", "text"],
        help="what the upstream can return. Probed from the endpoint when omitted, which is the "
        "recommended path; pass it only to force a level.",
    )
    parser.add_argument(
        "--admin-key",
        default=os.environ.get("OPENENV_CAPTURE_ADMIN_KEY", ""),
        help="key the session-management routes (/sessions*) require (defaults to "
        "$OPENENV_CAPTURE_ADMIN_KEY). Required for non-loopback binds. Never printed or logged.",
    )
    args = parser.parse_args()

    import uvicorn

    logging.basicConfig(
        level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
    )

    level = args.capture_level
    if not level:
        from .validate_llm import validate_llm

        report = validate_llm(
            args.llm_url,
            args.model or "",
            api_key=args.api_key or None,
            auth_header=args.auth_header,
        )
        if not report.reachable:
            raise SystemExit(report.summary())
        level = report.capture_level
        print(f"capture level: {level} ({report.rollout_type} rollouts)")
        for fix in report.param_fixes:
            print(f"  upstream compat: {fix}")

    # Flag/env only — never mint-and-print (that would log a credential). Loopback may leave the
    # key unset for private local use; non-loopback must supply one explicitly.
    admin_key = args.admin_key or None
    if not admin_key and not _is_loopback_host(args.host):
        raise SystemExit(
            f"--admin-key or $OPENENV_CAPTURE_ADMIN_KEY is required when --host={args.host} "
            "(non-loopback bind would publish /sessions* ungated). "
            "Pass --host 127.0.0.1 for a private local port, or set a key."
        )

    uvicorn.run(
        create_app(
            llm_url=args.llm_url,
            model=args.model,
            engine=args.engine,
            require_registered=not args.allow_unregistered,
            max_output_tokens=args.max_output_tokens or None,
            api_key=args.api_key or None,
            auth_header=args.auth_header,
            capture_level=level,
            admin_key=admin_key,
        ),
        host=args.host,
        port=args.port,
        log_level="info",
    )


if __name__ == "__main__":
    main()