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.
- roller/__init__.py +31 -0
- roller/__main__.py +6 -0
- roller/actf.py +431 -0
- roller/artifacts.py +124 -0
- roller/assets/deepseek/journal.mjs +44 -0
- roller/assets/deepseek/roller.patch.yml +15 -0
- roller/assets/deepseek/runner.py +149 -0
- roller/assets/hermes/runner.py +322 -0
- roller/assets/openclaw_capture.py +32 -0
- roller/assets/openclaw_drain.mjs +21 -0
- roller/assets/opencode/journal.mjs +42 -0
- roller/assets/opencode/runner.py +203 -0
- roller/assets/pi/runner.mjs +160 -0
- roller/assets/pi/runner.py +93 -0
- roller/benches/__init__.py +73 -0
- roller/benches/base.py +306 -0
- roller/benches/grading.py +553 -0
- roller/benches/pinchbench.py +976 -0
- roller/benches/qwenclaw/__init__.py +49 -0
- roller/benches/qwenclaw/adapter.py +167 -0
- roller/benches/qwenclaw/grader_runtime.py +395 -0
- roller/benches/qwenclaw/report.py +49 -0
- roller/benches/qwenclaw/task_parser.py +181 -0
- roller/benches/qwenclaw/workspace.py +202 -0
- roller/benches/report.py +166 -0
- roller/benches/skillsbench.py +708 -0
- roller/benches/transcripts.py +102 -0
- roller/benches/wildclaw.py +569 -0
- roller/bridge_capture.py +73 -0
- roller/bundles.py +545 -0
- roller/capture.py +385 -0
- roller/capture_protocol.py +301 -0
- roller/capture_runtime.py +48 -0
- roller/capture_states.py +37 -0
- roller/claude_capture.py +185 -0
- roller/claude_code_actf.py +594 -0
- roller/cli.py +1732 -0
- roller/codex_actf.py +584 -0
- roller/codex_capture.py +99 -0
- roller/codex_tools.py +137 -0
- roller/collector.py +818 -0
- roller/compaction_sft.py +672 -0
- roller/conversation.py +432 -0
- roller/deepseek_actf.py +135 -0
- roller/harness_registry.py +61 -0
- roller/harnesses/__init__.py +29 -0
- roller/harnesses/base.py +64 -0
- roller/harnesses/bridge.py +448 -0
- roller/harnesses/claude_code.py +839 -0
- roller/harnesses/codex.py +808 -0
- roller/harnesses/config_overrides.py +100 -0
- roller/harnesses/container.py +206 -0
- roller/harnesses/deepseek.py +104 -0
- roller/harnesses/docker.py +72 -0
- roller/harnesses/gateway_ports.py +155 -0
- roller/harnesses/hermes.py +119 -0
- roller/harnesses/openclaw.py +1605 -0
- roller/harnesses/opencode.py +193 -0
- roller/harnesses/pi.py +111 -0
- roller/hermes_actf.py +147 -0
- roller/image_build.py +206 -0
- roller/integrity.py +332 -0
- roller/model_protocols.py +201 -0
- roller/models.py +169 -0
- roller/openclaw_capture.py +140 -0
- roller/openclaw_delivery.py +211 -0
- roller/openclaw_usage.py +59 -0
- roller/opencode_actf.py +305 -0
- roller/opencode_capture.py +96 -0
- roller/pi_actf.py +172 -0
- roller/response_metadata.py +89 -0
- roller/run_lock.py +297 -0
- roller/sft.py +471 -0
- roller/storage.py +53 -0
- roller/tasks.py +689 -0
- roller/thinking.py +50 -0
- roller/thinking_capabilities.py +152 -0
- roller/trajectory_audit.py +386 -0
- roller/usage.py +168 -0
- roller/version.py +1 -0
- roller/viewer/__init__.py +3 -0
- roller/viewer/app.py +722 -0
- roller/viewer/static/app.js +752 -0
- roller/viewer/static/index.html +105 -0
- roller/viewer/static/style.css +213 -0
- roller/wire_trace.py +650 -0
- roller_agent-0.2.0.dist-info/METADATA +397 -0
- roller_agent-0.2.0.dist-info/RECORD +92 -0
- roller_agent-0.2.0.dist-info/WHEEL +5 -0
- roller_agent-0.2.0.dist-info/entry_points.txt +2 -0
- roller_agent-0.2.0.dist-info/licenses/LICENSE +201 -0
- 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
|