roller-agent 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. roller/__init__.py +31 -0
  2. roller/__main__.py +6 -0
  3. roller/actf.py +431 -0
  4. roller/artifacts.py +124 -0
  5. roller/assets/deepseek/journal.mjs +44 -0
  6. roller/assets/deepseek/roller.patch.yml +15 -0
  7. roller/assets/deepseek/runner.py +149 -0
  8. roller/assets/hermes/runner.py +322 -0
  9. roller/assets/openclaw_capture.py +32 -0
  10. roller/assets/openclaw_drain.mjs +21 -0
  11. roller/assets/opencode/journal.mjs +42 -0
  12. roller/assets/opencode/runner.py +203 -0
  13. roller/assets/pi/runner.mjs +160 -0
  14. roller/assets/pi/runner.py +93 -0
  15. roller/benches/__init__.py +73 -0
  16. roller/benches/base.py +306 -0
  17. roller/benches/grading.py +553 -0
  18. roller/benches/pinchbench.py +976 -0
  19. roller/benches/qwenclaw/__init__.py +49 -0
  20. roller/benches/qwenclaw/adapter.py +167 -0
  21. roller/benches/qwenclaw/grader_runtime.py +395 -0
  22. roller/benches/qwenclaw/report.py +49 -0
  23. roller/benches/qwenclaw/task_parser.py +181 -0
  24. roller/benches/qwenclaw/workspace.py +202 -0
  25. roller/benches/report.py +166 -0
  26. roller/benches/skillsbench.py +708 -0
  27. roller/benches/transcripts.py +102 -0
  28. roller/benches/wildclaw.py +569 -0
  29. roller/bridge_capture.py +73 -0
  30. roller/bundles.py +545 -0
  31. roller/capture.py +385 -0
  32. roller/capture_protocol.py +301 -0
  33. roller/capture_runtime.py +48 -0
  34. roller/capture_states.py +37 -0
  35. roller/claude_capture.py +185 -0
  36. roller/claude_code_actf.py +594 -0
  37. roller/cli.py +1732 -0
  38. roller/codex_actf.py +584 -0
  39. roller/codex_capture.py +99 -0
  40. roller/codex_tools.py +137 -0
  41. roller/collector.py +818 -0
  42. roller/compaction_sft.py +672 -0
  43. roller/conversation.py +432 -0
  44. roller/deepseek_actf.py +135 -0
  45. roller/harness_registry.py +61 -0
  46. roller/harnesses/__init__.py +29 -0
  47. roller/harnesses/base.py +64 -0
  48. roller/harnesses/bridge.py +448 -0
  49. roller/harnesses/claude_code.py +839 -0
  50. roller/harnesses/codex.py +808 -0
  51. roller/harnesses/config_overrides.py +100 -0
  52. roller/harnesses/container.py +206 -0
  53. roller/harnesses/deepseek.py +104 -0
  54. roller/harnesses/docker.py +72 -0
  55. roller/harnesses/gateway_ports.py +155 -0
  56. roller/harnesses/hermes.py +119 -0
  57. roller/harnesses/openclaw.py +1605 -0
  58. roller/harnesses/opencode.py +193 -0
  59. roller/harnesses/pi.py +111 -0
  60. roller/hermes_actf.py +147 -0
  61. roller/image_build.py +206 -0
  62. roller/integrity.py +332 -0
  63. roller/model_protocols.py +201 -0
  64. roller/models.py +169 -0
  65. roller/openclaw_capture.py +140 -0
  66. roller/openclaw_delivery.py +211 -0
  67. roller/openclaw_usage.py +59 -0
  68. roller/opencode_actf.py +305 -0
  69. roller/opencode_capture.py +96 -0
  70. roller/pi_actf.py +172 -0
  71. roller/response_metadata.py +89 -0
  72. roller/run_lock.py +297 -0
  73. roller/sft.py +471 -0
  74. roller/storage.py +53 -0
  75. roller/tasks.py +689 -0
  76. roller/thinking.py +50 -0
  77. roller/thinking_capabilities.py +152 -0
  78. roller/trajectory_audit.py +386 -0
  79. roller/usage.py +168 -0
  80. roller/version.py +1 -0
  81. roller/viewer/__init__.py +3 -0
  82. roller/viewer/app.py +722 -0
  83. roller/viewer/static/app.js +752 -0
  84. roller/viewer/static/index.html +105 -0
  85. roller/viewer/static/style.css +213 -0
  86. roller/wire_trace.py +650 -0
  87. roller_agent-0.2.0.dist-info/METADATA +397 -0
  88. roller_agent-0.2.0.dist-info/RECORD +92 -0
  89. roller_agent-0.2.0.dist-info/WHEEL +5 -0
  90. roller_agent-0.2.0.dist-info/entry_points.txt +2 -0
  91. roller_agent-0.2.0.dist-info/licenses/LICENSE +201 -0
  92. roller_agent-0.2.0.dist-info/top_level.txt +1 -0
roller/wire_trace.py ADDED
@@ -0,0 +1,650 @@
1
+ """Wire-level request recorder and thinking-history compatibility proxy.
2
+
3
+ Roller points the harness's model ``baseUrl`` at a local instance of this proxy.
4
+ The proxy records what the real server would receive — full tools schema,
5
+ sampling parameters and (optionally) complete message bodies — then forwards
6
+ the request. Depending on configuration, it can also strip historical thinking
7
+ from requests or normalize equivalent Chat Completions reasoning response fields
8
+ before OpenClaw receives them. Because interception happens at the
9
+ OpenAI-compatible HTTP layer, it works for harness versions that honour
10
+ ``baseUrl``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import io
16
+ import json
17
+ import threading
18
+ import time
19
+ import zlib
20
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
21
+ from pathlib import Path
22
+ from typing import Any, Mapping, Sequence
23
+ from urllib import request as urllib_request
24
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
25
+ from roller.response_metadata import ResponseMetadata
26
+
27
+ from roller.codex_tools import tool_snapshot
28
+ from roller.capture import CallRecorder
29
+
30
+
31
+ HOP_BY_HOP_HEADERS = {
32
+ "connection",
33
+ "keep-alive",
34
+ "proxy-authenticate",
35
+ "proxy-authorization",
36
+ "te",
37
+ "trailers",
38
+ "transfer-encoding",
39
+ "upgrade",
40
+ "content-length",
41
+ }
42
+
43
+ BODY_KEYS = {"messages", "tools", "input"}
44
+
45
+ SENSITIVE_KEY_NAMES = {
46
+ "authorization",
47
+ "password",
48
+ "secret",
49
+ "token",
50
+ "accesstoken",
51
+ "refreshtoken",
52
+ "clientsecret",
53
+ }
54
+ REDACTED_VALUE = "<redacted>"
55
+
56
+ THINKING_MESSAGE_KEYS = ("reasoning_content", "reasoning", "reasoning_text")
57
+ THINKING_BLOCK_TYPES = ("thinking", "reasoning")
58
+
59
+ ENCRYPTED_CONTENT_ERROR = "invalid_encrypted_content"
60
+ ENCRYPTED_CONTENT_RETRY_DELAY_SECONDS = 0.5
61
+ ERROR_INSPECTION_LIMIT = 64 * 1024
62
+
63
+
64
+ def _is_encrypted_content_rejection(payload: bytes, content_encoding: str) -> bool:
65
+ """Inspect a bounded copy; keep the original response intact for forwarding."""
66
+ if len(payload) > ERROR_INSPECTION_LIMIT:
67
+ return False
68
+ encoding = content_encoding.strip().lower()
69
+ try:
70
+ if encoding in {"gzip", "deflate"}:
71
+ decoder = zlib.decompressobj(31 if encoding == "gzip" else 15)
72
+ payload = decoder.decompress(payload, ERROR_INSPECTION_LIMIT + 1)
73
+ if len(payload) > ERROR_INSPECTION_LIMIT or not decoder.eof or decoder.unused_data:
74
+ return False
75
+ elif encoding not in {"", "identity"}:
76
+ return False
77
+ body = json.loads(payload)
78
+ except (ValueError, UnicodeError, RecursionError, zlib.error):
79
+ return False
80
+ error = body.get("error") if isinstance(body, dict) else None
81
+ return isinstance(error, dict) and error.get("code") == ENCRYPTED_CONTENT_ERROR
82
+
83
+
84
+ def normalize_chat_completion_reasoning_response(body: Any) -> Any:
85
+ """Normalize visible Chat Completions reasoning to ``reasoning_content``.
86
+
87
+ OpenClaw 2026.7.1 records ``reasoning``/``reasoning_text`` response deltas as
88
+ thinking blocks but only replays ``reasoning_content`` for a generic custom
89
+ provider. Normalizing those equivalent response fields before OpenClaw sees
90
+ them preserves the text in later request history without inventing content.
91
+ """
92
+ if not isinstance(body, dict):
93
+ return body
94
+ choices = body.get("choices")
95
+ if not isinstance(choices, list):
96
+ return body
97
+ for choice in choices:
98
+ if not isinstance(choice, dict):
99
+ continue
100
+ for container_name in ("delta", "message"):
101
+ message = choice.get(container_name)
102
+ if not isinstance(message, dict):
103
+ continue
104
+ current = message.get("reasoning_content")
105
+ if not isinstance(current, str) or not current:
106
+ for source_name in ("reasoning", "reasoning_text"):
107
+ source = message.get(source_name)
108
+ if isinstance(source, str) and source:
109
+ message["reasoning_content"] = source
110
+ break
111
+ message.pop("reasoning", None)
112
+ message.pop("reasoning_text", None)
113
+ return body
114
+
115
+
116
+ def strip_thinking_from_body(body: Any) -> Any:
117
+ """Drop thinking/reasoning content from request history.
118
+
119
+ Applied before forwarding so the model never receives prior-turn thinking;
120
+ the current turn's reasoning streams back untouched. Handles both Chat
121
+ Completions ``messages`` and Responses API ``input`` items.
122
+ """
123
+ if not isinstance(body, dict):
124
+ return body
125
+ messages = body.get("messages")
126
+ if isinstance(messages, list):
127
+ for message in messages:
128
+ if not isinstance(message, dict) or message.get("role") != "assistant":
129
+ continue
130
+ for key in THINKING_MESSAGE_KEYS:
131
+ message.pop(key, None)
132
+ content = message.get("content")
133
+ if isinstance(content, list):
134
+ message["content"] = [
135
+ block
136
+ for block in content
137
+ if not (
138
+ isinstance(block, dict)
139
+ and block.get("type") in THINKING_BLOCK_TYPES
140
+ )
141
+ ]
142
+ responses_input = body.get("input")
143
+ if isinstance(responses_input, list):
144
+ body["input"] = [
145
+ item
146
+ for item in responses_input
147
+ if not (isinstance(item, dict) and item.get("type") in THINKING_BLOCK_TYPES)
148
+ ]
149
+ return body
150
+
151
+
152
+ def _normalized_key(value: Any) -> str:
153
+ return "".join(character for character in str(value).lower() if character.isalnum())
154
+
155
+
156
+ def redact_sensitive_values(value: Any) -> Any:
157
+ """Return a JSON-compatible copy with credentials replaced by a marker."""
158
+ if isinstance(value, dict):
159
+ redacted: dict[Any, Any] = {}
160
+ for key, item in value.items():
161
+ normalized = _normalized_key(key)
162
+ if (
163
+ normalized in SENSITIVE_KEY_NAMES
164
+ or normalized.endswith("apikey")
165
+ or normalized.endswith("token")
166
+ ):
167
+ redacted[key] = REDACTED_VALUE
168
+ else:
169
+ redacted[key] = redact_sensitive_values(item)
170
+ return redacted
171
+ if isinstance(value, list):
172
+ return [redact_sensitive_values(item) for item in value]
173
+ return value
174
+
175
+
176
+ def redact_jsonl_file(path: Path) -> None:
177
+ """Atomically redact credentials in a JSONL file, preserving malformed lines."""
178
+ temporary = path.with_name(f".{path.name}.redacted.tmp")
179
+ try:
180
+ with path.open("r", encoding="utf-8", errors="replace") as source, temporary.open(
181
+ "w", encoding="utf-8"
182
+ ) as destination:
183
+ for line in source:
184
+ try:
185
+ parsed = json.loads(line)
186
+ except json.JSONDecodeError:
187
+ destination.write(line)
188
+ continue
189
+ destination.write(
190
+ json.dumps(redact_sensitive_values(parsed), ensure_ascii=False) + "\n"
191
+ )
192
+ temporary.replace(path)
193
+ finally:
194
+ temporary.unlink(missing_ok=True)
195
+
196
+
197
+ def extract_wire_tools(path: Path) -> list[Any] | None:
198
+ """Merge every tools schema recorded across wire-trace requests."""
199
+ try:
200
+ # JSONL records are separated by LF. Unicode line separators can
201
+ # legally occur inside string values and must not split a record.
202
+ lines = path.read_text(encoding="utf-8", errors="replace").split("\n")
203
+ except OSError:
204
+ return None
205
+ merged: dict[str, Any] = {}
206
+ for line in lines:
207
+ try:
208
+ entry = json.loads(line)
209
+ except json.JSONDecodeError:
210
+ continue
211
+ if not isinstance(entry, dict):
212
+ continue
213
+ if entry.get("kind") == "tools":
214
+ tools = entry.get("tools")
215
+ else:
216
+ body = entry.get("body")
217
+ tools = body.get("tools") if isinstance(body, dict) else None
218
+ if not isinstance(tools, list):
219
+ continue
220
+ for tool in tools:
221
+ if not isinstance(tool, dict):
222
+ continue
223
+ function = tool.get("function")
224
+ source = function if isinstance(function, dict) else tool
225
+ name = source.get("name")
226
+ if isinstance(name, str) and name:
227
+ merged[name] = tool
228
+ return list(merged.values()) or None
229
+
230
+
231
+ class _ProxyHandler(BaseHTTPRequestHandler):
232
+ protocol_version = "HTTP/1.1"
233
+
234
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - stdlib signature
235
+ return
236
+
237
+ def _record(self, body: bytes | None) -> int:
238
+ proxy: WireTraceProxy = self.server.proxy # type: ignore[attr-defined]
239
+ capture_responses_tools = (
240
+ proxy.responses_tools_path is not None
241
+ and self.command == "POST"
242
+ and urlsplit(self.path).path.rstrip("/").endswith("/responses")
243
+ )
244
+ entry: dict[str, Any] = {
245
+ "seq": proxy.next_seq(responses_request=capture_responses_tools),
246
+ "ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime()) + "Z",
247
+ "kind": "request",
248
+ "method": self.command,
249
+ "path": self.path,
250
+ }
251
+ upstream_path = proxy.forward_path(self.path)
252
+ if upstream_path != self.path:
253
+ entry["upstream_path"] = upstream_path
254
+ dropped_headers = sorted(
255
+ name
256
+ for name in self.headers
257
+ if name.lower() in proxy.drop_request_headers
258
+ )
259
+ if dropped_headers:
260
+ entry["dropped_request_headers"] = dropped_headers
261
+ parsed: dict[str, Any] | None = None
262
+ if body:
263
+ try:
264
+ candidate = json.loads(body)
265
+ parsed = candidate if isinstance(candidate, dict) else None
266
+ except (json.JSONDecodeError, UnicodeDecodeError):
267
+ parsed = None
268
+ if capture_responses_tools and parsed is None:
269
+ raise ValueError("Cannot capture tools from a non-object Responses request")
270
+ if parsed is not None:
271
+ entry["model"] = parsed.get("model")
272
+ if capture_responses_tools:
273
+ proxy.write_tools({
274
+ "kind": "codex_tools", "seq": entry["seq"],
275
+ "snapshot": tool_snapshot(parsed),
276
+ })
277
+ if proxy.mode == "full":
278
+ entry["body"] = parsed
279
+ else:
280
+ params = {k: v for k, v in parsed.items() if k not in BODY_KEYS}
281
+ entry["params"] = params
282
+ messages = parsed.get("messages")
283
+ if isinstance(messages, list):
284
+ entry["n_messages"] = len(messages)
285
+ tools = parsed.get("tools")
286
+ if isinstance(tools, list) and tools:
287
+ fingerprint = json.dumps(
288
+ tools,
289
+ ensure_ascii=False,
290
+ sort_keys=True,
291
+ separators=(",", ":"),
292
+ )
293
+ if fingerprint not in proxy.tool_schema_fingerprints:
294
+ proxy.tool_schema_fingerprints.add(fingerprint)
295
+ entry["kind"] = "tools"
296
+ entry["tools"] = tools
297
+ entry["n_tools"] = len(tools)
298
+ proxy.write(entry)
299
+ if (proxy.capture_recorder is not None and parsed is not None
300
+ and self.command == "POST"
301
+ and urlsplit(self.path).path.rstrip("/").endswith(("/messages", "/chat/completions", "/responses"))):
302
+ native_session = self.headers.get(proxy.capture_identity_header) if proxy.capture_identity_header else None
303
+ native_run = self.headers.get(proxy.capture_run_header) if proxy.capture_run_header else None
304
+ proxy.capture_recorder.begin(entry["seq"], proxy.forward_path(self.path), parsed,
305
+ native_identity={**({"session_id": native_session} if native_session else {}),
306
+ **({"run_id": native_run} if native_run else {})} or None)
307
+ return entry["seq"]
308
+
309
+ def _handle(self) -> None:
310
+ proxy: WireTraceProxy = self.server.proxy # type: ignore[attr-defined]
311
+ length = self.headers.get("Content-Length")
312
+ body = self.rfile.read(int(length)) if length and length.isdigit() else None
313
+ if body and proxy.strip_thinking:
314
+ try:
315
+ parsed = strip_thinking_from_body(json.loads(body))
316
+ body = json.dumps(parsed, ensure_ascii=False).encode("utf-8")
317
+ except (json.JSONDecodeError, UnicodeDecodeError):
318
+ pass
319
+ request_seq = None
320
+ try:
321
+ request_seq = self._record(body)
322
+ except Exception as exc:
323
+ if proxy.responses_tools_path is not None:
324
+ proxy.recording_error = str(exc)
325
+ if proxy.capture_recorder is not None:
326
+ proxy.capture_recorder.error = str(exc)
327
+ self.close_connection = True
328
+ url = proxy.upstream_origin + proxy.forward_path(self.path)
329
+ outgoing = urllib_request.Request(url, data=body, method=self.command)
330
+ for key, value in self.headers.items():
331
+ normalized_key = key.lower()
332
+ if (
333
+ normalized_key not in HOP_BY_HOP_HEADERS
334
+ and normalized_key != "host"
335
+ and normalized_key not in proxy.drop_request_headers
336
+ ):
337
+ outgoing.add_header(key, value)
338
+ try:
339
+ response = proxy.open_upstream(outgoing, request_seq)
340
+ except Exception as exc:
341
+ if proxy.capture_recorder is not None and request_seq in proxy.capture_recorder.request_seqs:
342
+ proxy.capture_recorder.finish(request_seq, status=None, content_type="",
343
+ content_encoding="", complete=False, error=f"upstream transport error: {type(exc).__name__}")
344
+ payload = json.dumps({"error": str(exc)}).encode("utf-8")
345
+ self.send_response(502)
346
+ self.send_header("Content-Type", "application/json")
347
+ self.send_header("Content-Length", str(len(payload)))
348
+ self.end_headers()
349
+ self.wfile.write(payload)
350
+ return
351
+ status = getattr(response, "status", None)
352
+ if not isinstance(status, int):
353
+ status = getattr(response, "code", None)
354
+ self.send_response(status if isinstance(status, int) else 502)
355
+ for key, value in response.headers.items():
356
+ if key.lower() not in HOP_BY_HOP_HEADERS:
357
+ self.send_header(key, value)
358
+ self.send_header("Connection", "close")
359
+ self.end_headers()
360
+ content_type = response.headers.get("Content-Type", "").lower()
361
+ content_encoding = response.headers.get("Content-Encoding", "").lower()
362
+ observer = ResponseMetadata(content_type, content_encoding)
363
+ recorder = proxy.capture_recorder
364
+ captured = recorder is not None and request_seq in recorder.request_seqs
365
+ response_file = None
366
+ capture_error = None
367
+ complete = False
368
+ if captured:
369
+ try:
370
+ response_file = recorder.open_response(request_seq)
371
+ except Exception as exc:
372
+ capture_error = recorder.error = str(exc)
373
+
374
+ def observe(chunk: bytes) -> None:
375
+ nonlocal capture_error
376
+ observer.feed(chunk)
377
+ if response_file is not None:
378
+ try:
379
+ recorder.append_response(response_file, chunk)
380
+ except Exception as exc:
381
+ capture_error = recorder.error = str(exc)
382
+
383
+ def record_response() -> None:
384
+ # Diagnostics must never prevent delivery of the original response.
385
+ if isinstance(request_seq, int):
386
+ try:
387
+ proxy.write({"kind": "response_metadata", "request_seq": request_seq,
388
+ "http_status": status, **observer.finish()})
389
+ except Exception:
390
+ pass
391
+ normalize_reasoning = (
392
+ proxy.normalize_reasoning_replay
393
+ and self.path.split("?", 1)[0].rstrip("/").endswith("/chat/completions")
394
+ and content_encoding in ("", "identity")
395
+ )
396
+ try:
397
+ if normalize_reasoning and "text/event-stream" in content_type:
398
+ while True:
399
+ line = response.readline()
400
+ if not line:
401
+ break
402
+ observe(line)
403
+ self.wfile.write(self._normalize_sse_reasoning_line(line))
404
+ self.wfile.flush()
405
+ elif normalize_reasoning and "json" in content_type:
406
+ payload = response.read()
407
+ observe(payload)
408
+ try:
409
+ payload = json.dumps(normalize_chat_completion_reasoning_response(
410
+ json.loads(payload)), ensure_ascii=False).encode("utf-8")
411
+ except (json.JSONDecodeError, UnicodeDecodeError):
412
+ pass
413
+ self.wfile.write(payload)
414
+ self.wfile.flush()
415
+ else:
416
+ while True:
417
+ # read1 forwards available SSE bytes without waiting for a
418
+ # 64 KiB buffer or the end of the upstream response.
419
+ chunk = response.read1(65536) if captured and hasattr(response, "read1") else response.read(65536)
420
+ if not chunk:
421
+ break
422
+ observe(chunk)
423
+ self.wfile.write(chunk)
424
+ self.wfile.flush()
425
+ complete = True
426
+ except Exception as exc:
427
+ capture_error = f"response forwarding interrupted: {type(exc).__name__}"
428
+ raise
429
+ finally:
430
+ if response_file is not None:
431
+ try:
432
+ response_file.close()
433
+ except Exception as exc:
434
+ capture_error = recorder.error = str(exc)
435
+ if captured:
436
+ recorder.finish(request_seq, status=status, content_type=content_type,
437
+ content_encoding=content_encoding, complete=complete and not capture_error,
438
+ error=capture_error)
439
+ record_response()
440
+ close_response = getattr(response, "close", None)
441
+ if callable(close_response):
442
+ close_response()
443
+
444
+ @staticmethod
445
+ def _normalize_sse_reasoning_line(line: bytes) -> bytes:
446
+ newline = b"\r\n" if line.endswith(b"\r\n") else b"\n" if line.endswith(b"\n") else b""
447
+ content = line[: -len(newline)] if newline else line
448
+ if not content.startswith(b"data:"):
449
+ return line
450
+ data = content[5:].lstrip()
451
+ if not data or data == b"[DONE]":
452
+ return line
453
+ try:
454
+ parsed = json.loads(data)
455
+ normalized = json.dumps(
456
+ normalize_chat_completion_reasoning_response(parsed),
457
+ ensure_ascii=False,
458
+ separators=(",", ":"),
459
+ ).encode("utf-8")
460
+ except (json.JSONDecodeError, UnicodeDecodeError):
461
+ return line
462
+ return b"data: " + normalized + newline
463
+
464
+ do_GET = do_POST = do_PUT = do_DELETE = do_PATCH = _handle
465
+
466
+
467
+ class WireTraceProxy:
468
+ """Reverse proxy that records wire-level model API requests to a JSONL file."""
469
+
470
+ def __init__(
471
+ self,
472
+ upstream_base_url: str,
473
+ log_path: Path,
474
+ mode: str = "tools",
475
+ host: str = "127.0.0.1",
476
+ timeout_seconds: int = 600,
477
+ strip_thinking: bool = False,
478
+ normalize_reasoning_replay: bool = False,
479
+
480
+ drop_query_parameters: Sequence[str] = (),
481
+ drop_request_headers: Sequence[str] = (),
482
+ responses_tools_path: Path | None = None,
483
+ capture_recorder: CallRecorder | None = None,
484
+ capture_identity_header: str | None = None,
485
+ capture_run_header: str | None = None,
486
+ encrypted_content_retries: int = 0,
487
+ ) -> None:
488
+ if mode not in ("tools", "full"):
489
+ raise ValueError(f"unknown wire trace mode: {mode}")
490
+ if encrypted_content_retries < 0:
491
+ raise ValueError("encrypted content retries must be non-negative")
492
+ parts = urlsplit(upstream_base_url)
493
+ if not parts.scheme or not parts.netloc:
494
+ raise ValueError(f"upstream base URL must be absolute: {upstream_base_url}")
495
+ self.upstream_origin = f"{parts.scheme}://{parts.netloc}"
496
+ self.base_path = parts.path.rstrip("/")
497
+ self.log_path = log_path
498
+ self.mode = mode
499
+ self.responses_tools_path = responses_tools_path
500
+ self.capture_recorder = capture_recorder
501
+ self.capture_identity_header = capture_identity_header
502
+ self.capture_run_header = capture_run_header
503
+ self.recording_error: str | None = None
504
+ self.strip_thinking = strip_thinking
505
+ self.normalize_reasoning_replay = normalize_reasoning_replay
506
+ self.drop_query_parameters = frozenset(drop_query_parameters)
507
+ self.drop_request_headers = frozenset(
508
+ name.strip().lower() for name in drop_request_headers if name.strip()
509
+ )
510
+ if capture_identity_header and capture_identity_header.lower().startswith("x-roller-"):
511
+ self.drop_request_headers |= {capture_identity_header.lower()}
512
+ if capture_run_header and capture_run_header.lower().startswith("x-roller-"):
513
+ self.drop_request_headers |= {capture_run_header.lower()}
514
+ self.timeout_seconds = timeout_seconds
515
+ self.encrypted_content_retries = encrypted_content_retries
516
+ self._lock = threading.Lock()
517
+ self._stopping = threading.Event()
518
+ self._seq = 0
519
+ self.responses_request_count = 0
520
+ self.tool_schema_fingerprints: set[str] = set()
521
+ self._server: ThreadingHTTPServer | None = None
522
+ self._thread: threading.Thread | None = None
523
+ self._host = host
524
+
525
+ def start(self) -> None:
526
+ self.log_path.parent.mkdir(parents=True, exist_ok=True)
527
+ self._server = ThreadingHTTPServer((self._host, 0), _ProxyHandler)
528
+ self._server.daemon_threads = True
529
+ self._server.proxy = self # type: ignore[attr-defined]
530
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
531
+ self._thread.start()
532
+
533
+ def stop(self) -> None:
534
+ # Wake pending retries before closing the listener; daemon handlers can
535
+ # outlive the server and must not initiate another model request.
536
+ self._stopping.set()
537
+ if self._server is not None:
538
+ self._server.shutdown()
539
+ self._server.server_close()
540
+ self._server = None
541
+ if self._thread is not None:
542
+ self._thread.join(timeout=5)
543
+ self._thread = None
544
+
545
+ @property
546
+ def port(self) -> int:
547
+ if self._server is None:
548
+ raise RuntimeError("proxy is not running")
549
+ return self._server.server_address[1]
550
+
551
+ @property
552
+ def proxy_base_url(self) -> str:
553
+ """The baseUrl handed to the harness; preserves the upstream path prefix."""
554
+ return f"http://127.0.0.1:{self.port}{self.base_path}"
555
+
556
+ def rewrite_base_url(self, current: str) -> bool:
557
+ """True when ``current`` points at this proxy's upstream and should be rewritten."""
558
+ return urlsplit(current).netloc == urlsplit(
559
+ f"{self.upstream_origin}{self.base_path}"
560
+ ).netloc and urlsplit(current).path.rstrip("/") == self.base_path
561
+
562
+ def forward_path(self, path: str) -> str:
563
+ """Return the upstream request target after scoped compatibility rewrites."""
564
+
565
+ if not self.drop_query_parameters:
566
+ return path
567
+ parts = urlsplit(path)
568
+ query = urlencode(
569
+ [
570
+ (name, value)
571
+ for name, value in parse_qsl(parts.query, keep_blank_values=True)
572
+ if name not in self.drop_query_parameters
573
+ ]
574
+ )
575
+ return urlunsplit(("", "", parts.path, query, ""))
576
+
577
+ def open_upstream(
578
+ self, outgoing: urllib_request.Request, request_seq: int | None
579
+ ) -> Any:
580
+ """Open the upstream response, resending transient replay rejections.
581
+
582
+ Relayed Responses endpoints can intermittently fail to verify their own
583
+ ``reasoning.encrypted_content`` when Codex replays it. The 400 arrives
584
+ before generation starts, so the identical request is safe to resend.
585
+ """
586
+ retryable = (
587
+ outgoing.get_method() == "POST"
588
+ and urlsplit(outgoing.full_url).path.rstrip("/").endswith("/responses")
589
+ )
590
+ attempt = 0
591
+ while not self._stopping.is_set():
592
+ try:
593
+ return urllib_request.urlopen(outgoing, timeout=self.timeout_seconds)
594
+ except urllib_request.HTTPError as exc: # 4xx/5xx still carry a body
595
+ if not retryable or exc.code != 400 or attempt >= self.encrypted_content_retries:
596
+ return exc
597
+ payload = exc.read()
598
+ exc.close()
599
+ buffered = urllib_request.HTTPError(
600
+ exc.filename, exc.code, exc.msg, exc.headers, io.BytesIO(payload)
601
+ )
602
+ if not _is_encrypted_content_rejection(
603
+ payload, exc.headers.get("Content-Encoding", "")
604
+ ):
605
+ return buffered
606
+ if self._stopping.wait(ENCRYPTED_CONTENT_RETRY_DELAY_SECONDS * (attempt + 1)):
607
+ return buffered
608
+ buffered.close()
609
+ attempt += 1
610
+ try:
611
+ self.write({"kind": "upstream_retry", "request_seq": request_seq,
612
+ "attempt": attempt, "http_status": 400,
613
+ "reason": ENCRYPTED_CONTENT_ERROR})
614
+ except Exception:
615
+ pass
616
+ raise OSError("wire proxy stopped")
617
+
618
+ def next_seq(self, *, responses_request: bool = False) -> int:
619
+ with self._lock:
620
+ self._seq += 1
621
+ if responses_request:
622
+ self.responses_request_count += 1
623
+ return self._seq
624
+
625
+ def write_tools(self, entry: Mapping[str, Any]) -> None:
626
+ assert self.responses_tools_path is not None
627
+ with self._lock, self.responses_tools_path.open("a", encoding="utf-8") as stream:
628
+ stream.write(json.dumps(entry, ensure_ascii=False) + "\n")
629
+
630
+ def write(self, entry: Mapping[str, Any]) -> None:
631
+ line = json.dumps(entry, ensure_ascii=False)
632
+ with self._lock:
633
+ with self.log_path.open("a", encoding="utf-8") as stream:
634
+ stream.write(line + "\n")
635
+
636
+
637
+ def rewrite_model_config(
638
+ config: Mapping[str, Any], proxy: WireTraceProxy
639
+ ) -> dict[str, Any]:
640
+ """Deep-copy a model config, rewriting matching provider baseUrls to the proxy."""
641
+ rewritten = json.loads(json.dumps(config))
642
+ providers = rewritten.get("providers")
643
+ if isinstance(providers, dict):
644
+ for provider in providers.values():
645
+ if not isinstance(provider, dict):
646
+ continue
647
+ base_url = provider.get("baseUrl")
648
+ if isinstance(base_url, str) and proxy.rewrite_base_url(base_url):
649
+ provider["baseUrl"] = proxy.proxy_base_url
650
+ return rewritten