premanmcp 1.1.13 → 1.1.14

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.
@@ -0,0 +1,828 @@
1
+ """PreMan's recording proxy for agents run as another process (Node, Go, Rust, any CLI).
2
+
3
+ The wrapper points the agent's OPENAI_BASE_URL / ANTHROPIC_BASE_URL here. Every
4
+ request and reply is forwarded unchanged to the address the agent would have
5
+ used; a copy is read for tool calls. A call is recorded only when both halves
6
+ were seen: the model asking for it, and its result going back to the model.
7
+
8
+ Shipped with the CLI and imported by generated wrappers (design spec §6.0).
9
+ Standard library only: the interpreter running those wrappers has no
10
+ guaranteed openai or anthropic package.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import gzip
15
+ import http.client
16
+ import http.server
17
+ import json
18
+ import os
19
+ import secrets
20
+ import threading
21
+ import urllib.error
22
+ import urllib.request
23
+ import zlib
24
+ from pathlib import Path
25
+
26
+ MAX_BODY = 2 * 1024 * 1024
27
+
28
+
29
+ def kind_of(provider: str, path: str) -> str:
30
+ """Which model API a request path is, or "" for one that carries no turn
31
+ (model lists, embeddings, token counting)."""
32
+ tail = path.split("?", 1)[0].rstrip("/")
33
+ if provider == "openai":
34
+ if tail.endswith("/chat/completions"):
35
+ return "openai_chat"
36
+ if tail.endswith("/responses"):
37
+ return "openai_responses"
38
+ if provider == "anthropic" and tail.endswith("/messages"):
39
+ return "anthropic"
40
+ return ""
41
+
42
+
43
+ def _args(raw):
44
+ if isinstance(raw, dict):
45
+ return raw
46
+ if isinstance(raw, str):
47
+ try:
48
+ value = json.loads(raw) if raw.strip() else {}
49
+ except ValueError:
50
+ return {"raw": raw}
51
+ return value if isinstance(value, dict) else {"raw": value}
52
+ return {}
53
+
54
+
55
+ def _text(content) -> str:
56
+ if isinstance(content, str):
57
+ return content
58
+ if isinstance(content, list):
59
+ parts = [
60
+ block["text"] if isinstance(block, dict) else block
61
+ for block in content
62
+ if (isinstance(block, dict) and isinstance(block.get("text"), str)) or isinstance(block, str)
63
+ ]
64
+ if parts:
65
+ return "".join(parts)
66
+ return json.dumps(content, ensure_ascii=False, default=str)
67
+
68
+
69
+ class _Unreadable(Exception):
70
+ pass
71
+
72
+
73
+ _NO_ID = "a tool call or tool result carried no id, so PreMan cannot pair them"
74
+ _NO_TRAFFIC = (
75
+ "no model request reached PreMan; the agent may set its model address in code "
76
+ "instead of reading OPENAI_BASE_URL or ANTHROPIC_BASE_URL"
77
+ )
78
+ _CHANNEL_CLOSED = "the turn's proxy channel was closed before the turn ended"
79
+ # The only outcomes where the proxy saw nothing of the turn, so the agent's own
80
+ # report is the better account. Any other "not visible" is something the proxy
81
+ # saw and could not trust, and a report cannot undo that.
82
+ _NO_TOOLS_OFFERED = (
83
+ "the agent offered the model no tools in its requests, so PreMan cannot tell whether it ran any "
84
+ "(for example, tools named in plain text)"
85
+ )
86
+ _UNSEEN = (_NO_TRAFFIC, _CHANNEL_CLOSED, _NO_TOOLS_OFFERED)
87
+
88
+
89
+ def _call(out, cid, name, args):
90
+ # A call with no id cannot be paired with its result, so it may run unseen.
91
+ # One with no function name (a custom tool) is kept with name None: in a
92
+ # reply it makes the turn not visible; in history it is skipped, so its
93
+ # result has no readable request and the orphan rule decides.
94
+ if not cid:
95
+ raise _Unreadable(_NO_ID)
96
+ out.append((cid, name or None, _args(args)))
97
+
98
+
99
+ def _requests_in_reply(kind, reply):
100
+ """(id, name, args) for each tool the model asked for in one reply."""
101
+ out = []
102
+ if kind == "openai_chat":
103
+ for choice in reply.get("choices") or []:
104
+ message = (choice or {}).get("message") or {}
105
+ for call in message.get("tool_calls") or []:
106
+ fn = (call or {}).get("function") or {}
107
+ _call(out, (call or {}).get("id"), fn.get("name"), fn.get("arguments"))
108
+ elif kind == "openai_responses":
109
+ for item in reply.get("output") or []:
110
+ if isinstance(item, dict) and item.get("type") == "function_call":
111
+ _call(out, item.get("call_id"), item.get("name"), item.get("arguments"))
112
+ elif kind == "anthropic":
113
+ for block in reply.get("content") or []:
114
+ if isinstance(block, dict) and block.get("type") == "tool_use":
115
+ _call(out, block.get("id"), block.get("name"), block.get("input"))
116
+ return out
117
+
118
+
119
+ # Tool types PreMan cannot record: local ones whose shape it does not read, and
120
+ # ones the provider runs itself, so no result ever passes through the agent.
121
+ _UNREAD_TYPES = {
122
+ "openai_responses": {"computer_call", "local_shell_call", "shell_call", "apply_patch_call", "custom_tool_call"},
123
+ }
124
+ _HOSTED_TYPES = {
125
+ "openai_responses": {"web_search_call", "file_search_call", "mcp_call", "code_interpreter_call", "image_generation_call"},
126
+ "anthropic": {"server_tool_use", "mcp_tool_use", "web_search_tool_result"},
127
+ }
128
+
129
+
130
+ def _unrecorded(kind, items):
131
+ """(key, reason) for each item that is a tool PreMan cannot record. The key
132
+ is the item's id, or "" when it has none."""
133
+ out = []
134
+ for item in items if isinstance(items, list) else []:
135
+ kind_of_item = item.get("type") if isinstance(item, dict) else None
136
+ if not isinstance(kind_of_item, str):
137
+ continue
138
+ key = str(item.get("call_id") or item.get("id") or item.get("tool_use_id") or "")
139
+ if kind_of_item in _UNREAD_TYPES.get(kind, ()):
140
+ out.append((key, "the agent ran a tool type PreMan cannot read (%s)" % kind_of_item))
141
+ elif kind_of_item in _HOSTED_TYPES.get(kind, ()) or (
142
+ kind == "anthropic" and kind_of_item.endswith("_tool_result") and kind_of_item != "tool_result"
143
+ ):
144
+ out.append((key, "the model used a tool that runs on the provider's side (%s), which PreMan does not record" % kind_of_item))
145
+ return out
146
+
147
+
148
+ def _unrecorded_in_history(kind, request):
149
+ if kind == "openai_responses":
150
+ return _unrecorded(kind, request.get("input"))
151
+ if kind == "anthropic":
152
+ out = []
153
+ for message in request.get("messages") or []:
154
+ if isinstance(message, dict):
155
+ out += _unrecorded(kind, message.get("content"))
156
+ return out
157
+ return []
158
+
159
+
160
+ def _unrecorded_in_reply(kind, reply):
161
+ if kind == "openai_responses":
162
+ return _unrecorded(kind, reply.get("output"))
163
+ if kind == "anthropic":
164
+ return _unrecorded(kind, reply.get("content"))
165
+ return []
166
+
167
+
168
+ def _requests_in_history(kind, request):
169
+ """(id, name, args) for each tool request the agent re-sent as history."""
170
+ out = []
171
+ if kind == "openai_chat":
172
+ for message in request.get("messages") or []:
173
+ if isinstance(message, dict) and message.get("role") == "assistant":
174
+ out += _requests_in_reply("openai_chat", {"choices": [{"message": message}]})
175
+ elif kind == "openai_responses":
176
+ items = request.get("input")
177
+ out += _requests_in_reply("openai_responses", {"output": items if isinstance(items, list) else []})
178
+ elif kind == "anthropic":
179
+ for message in request.get("messages") or []:
180
+ if isinstance(message, dict) and message.get("role") == "assistant" and isinstance(message.get("content"), list):
181
+ out += _requests_in_reply("anthropic", {"content": message["content"]})
182
+ return out
183
+
184
+
185
+ def _results_in_request(kind, request):
186
+ """(id, content) for each tool result the agent sent back to the model."""
187
+ out = []
188
+ if kind == "openai_chat":
189
+ for idx, message in enumerate(request.get("messages") or []):
190
+ if isinstance(message, dict):
191
+ if message.get("role") == "tool":
192
+ if not message.get("tool_call_id"):
193
+ raise _Unreadable(_NO_ID)
194
+ out.append((message["tool_call_id"], _text(message.get("content"))))
195
+ elif message.get("role") == "function":
196
+ name = message.get("name", "function")
197
+ out.append(("function:%s:%d" % (name, idx), _text(message.get("content"))))
198
+ elif kind == "openai_responses":
199
+ items = request.get("input")
200
+ for idx, item in enumerate(items if isinstance(items, list) else []):
201
+ if not isinstance(item, dict) or not isinstance(item.get("type"), str):
202
+ continue
203
+ if item["type"] == "function_call_output":
204
+ if not item.get("call_id"):
205
+ raise _Unreadable(_NO_ID)
206
+ out.append((item["call_id"], _text(item.get("output"))))
207
+ elif item["type"].endswith("_call_output"):
208
+ # The result of a tool type PreMan cannot read (custom, shell,
209
+ # computer...). Keyed so it never pairs with a request: it is an
210
+ # orphan, and the orphan rule makes the turn not visible.
211
+ key = item.get("call_id") or item.get("id") or "%s:%d" % (item["type"], idx)
212
+ out.append(("unreadable:%s" % key, _text(item.get("output"))))
213
+ elif kind == "anthropic":
214
+ for message in request.get("messages") or []:
215
+ content = message.get("content") if isinstance(message, dict) else None
216
+ for block in content if isinstance(content, list) else []:
217
+ if isinstance(block, dict) and block.get("type") == "tool_result":
218
+ if not block.get("tool_use_id"):
219
+ raise _Unreadable(_NO_ID)
220
+ out.append((block["tool_use_id"], _text(block.get("content"))))
221
+ return out
222
+
223
+
224
+ def _sse(raw: bytes):
225
+ """The JSON payloads of a server-sent-events body, in order."""
226
+ for block in raw.decode("utf-8", "replace").replace("\r\n", "\n").split("\n\n"):
227
+ data = "\n".join(line[5:].lstrip() for line in block.split("\n") if line.startswith("data:"))
228
+ if not data or data == "[DONE]":
229
+ continue
230
+ try:
231
+ yield json.loads(data)
232
+ except ValueError:
233
+ raise _Unreadable("a streamed reply held a chunk that is not JSON")
234
+
235
+
236
+ def _requests_in_stream(kind, raw: bytes):
237
+ if kind == "openai_chat":
238
+ slots = {}
239
+ for event in _sse(raw):
240
+ for choice in event.get("choices") or []:
241
+ delta = (choice or {}).get("delta") or {}
242
+ for part in delta.get("tool_calls") or []:
243
+ slot = slots.setdefault((choice.get("index", 0), part.get("index", 0)), {"id": "", "name": "", "arguments": ""})
244
+ if part.get("id"):
245
+ slot["id"] = part["id"]
246
+ fn = part.get("function") or {}
247
+ slot["name"] += fn.get("name") or ""
248
+ slot["arguments"] += fn.get("arguments") or ""
249
+ out = []
250
+ for s in slots.values():
251
+ _call(out, s["id"], s["name"], s["arguments"])
252
+ return out
253
+ if kind == "openai_responses":
254
+ done = []
255
+ for event in _sse(raw):
256
+ if event.get("type") == "response.completed" and isinstance(event.get("response"), dict):
257
+ return _requests_in_reply(kind, event["response"])
258
+ if event.get("type") == "response.output_item.done" and isinstance(event.get("item"), dict):
259
+ done.append(event["item"])
260
+ return _requests_in_reply(kind, {"output": done})
261
+ if kind == "anthropic":
262
+ slots = {}
263
+ for event in _sse(raw):
264
+ if event.get("type") == "content_block_start":
265
+ block = event.get("content_block") or {}
266
+ if block.get("type") == "tool_use":
267
+ slots[event.get("index")] = {"id": block.get("id"), "name": block.get("name"), "input": block.get("input"), "json": ""}
268
+ elif event.get("type") == "content_block_delta":
269
+ delta = event.get("delta") or {}
270
+ if delta.get("type") == "input_json_delta" and event.get("index") in slots:
271
+ slots[event["index"]]["json"] += delta.get("partial_json") or ""
272
+ out = []
273
+ for s in slots.values():
274
+ _call(out, s["id"], s["name"], s["json"] if s["json"] else s["input"])
275
+ return out
276
+ return []
277
+
278
+
279
+ def _unrecorded_in_stream(kind, raw: bytes):
280
+ items = []
281
+ for event in _sse(raw):
282
+ if kind == "openai_responses":
283
+ if event.get("type") == "response.completed" and isinstance(event.get("response"), dict):
284
+ items += event["response"].get("output") or []
285
+ elif event.get("type") in ("response.output_item.added", "response.output_item.done"):
286
+ items.append(event.get("item"))
287
+ elif kind == "anthropic" and event.get("type") == "content_block_start":
288
+ items.append(event.get("content_block"))
289
+ return _unrecorded(kind, items)
290
+
291
+
292
+ def _decoded(body: bytes, encoding: str) -> bytes:
293
+ encoding = (encoding or "").strip().lower()
294
+ if encoding in ("", "identity"):
295
+ return body
296
+ if encoding == "gzip":
297
+ return gzip.decompress(body)
298
+ if encoding == "deflate":
299
+ return zlib.decompress(body)
300
+ raise _Unreadable("a model reply was compressed with %s" % encoding)
301
+
302
+
303
+ def _not_visible(reason):
304
+ return {"state": "not_visible", "source": "proxy", "watched": [], "missing": [], "reason": reason}
305
+
306
+
307
+ class _Turn:
308
+ """What one turn's model traffic showed. One per turn, discarded after it."""
309
+
310
+ def __init__(self, already=None, waiting=None):
311
+ self.requested = {} # id -> (name, args), history overrides
312
+ self.order = [] # ids in the order they were first seen
313
+ self.results = {} # id -> content
314
+ self.asked = [] # ids the model asked for in a reply during this turn
315
+ self.model_requests = 0
316
+ self.problem = ""
317
+ self.reused_id = False # a reply asked for an id an earlier turn already reported
318
+ self.reused_in_turn = False # a reply asked for an id already seen in this turn
319
+ self.offered_tools = False # some model request in the turn offered the model tools
320
+ self.last_reply_failed = False # the most recent model reply was an HTTP error
321
+ self.unrecorded = [] # keys of tool items PreMan cannot record, filed as reported at the end
322
+ self.already = already if already is not None else set()
323
+ # Ids asked for in an earlier turn of this channel whose result never
324
+ # went back, with their (name, args): a result for one arriving now
325
+ # counts in this turn, even when the agent does not re-send the call.
326
+ self.waiting = waiting if waiting is not None else {}
327
+ # The trace (design spec §7.5): each model reply that asks for a named
328
+ # call is one step; each call belongs to a conversation -- None for the
329
+ # top level, else the call a sub-agent ran inside.
330
+ self.step = 0
331
+ self.step_of = {}
332
+ self.conv_of = {}
333
+ self.parent_unknown = ""
334
+ self._counted = []
335
+ self._lock = threading.RLock() # the proxy serves one turn from several threads
336
+
337
+ def _note(self, cid, name, args, override=False):
338
+ if cid not in self.requested:
339
+ self.requested[cid] = (name, args)
340
+ self.order.append(cid)
341
+ elif override:
342
+ self.requested[cid] = (name, args)
343
+
344
+ def _fail(self, reason):
345
+ with self._lock:
346
+ self.problem = self.problem or reason
347
+
348
+ def saw_request(self, kind, body):
349
+ with self._lock:
350
+ return self._saw_request(kind, body)
351
+
352
+ def saw_reply(self, kind, status, content_type, content_encoding, body, truncated, conv=None):
353
+ with self._lock:
354
+ return self._saw_reply(kind, status, content_type, content_encoding, body, truncated, conv)
355
+
356
+ def outcome(self):
357
+ with self._lock:
358
+ events, capture = self._outcome()
359
+ capture["trace"] = self._trace(self._counted)
360
+ return events, capture
361
+
362
+ def _conversation(self, referenced):
363
+ """Which conversation a model request belongs to. One that re-sends or
364
+ answers a call continues that call's conversation; one that references
365
+ none, made while exactly one call is waiting, is a sub-agent running
366
+ inside that call. Anything less certain leaves parent unknown."""
367
+ seen = {self.conv_of[c] for c in referenced if c in self.conv_of}
368
+ if len(seen) == 1:
369
+ return seen.pop()
370
+ if len(seen) > 1:
371
+ self.parent_unknown = self.parent_unknown or "a model request mixed calls from two conversations"
372
+ return None
373
+ waiting = [c for c in self.asked if c not in self.results]
374
+ if len(waiting) > 1:
375
+ self.parent_unknown = self.parent_unknown or (
376
+ "a model request began while several tool calls were waiting, "
377
+ "so which one it ran inside is unclear"
378
+ )
379
+ return None
380
+ return waiting[0] if waiting else None
381
+
382
+ def _trace(self, counted):
383
+ """step and parent for the calls this turn counted, numbered in the order asked."""
384
+ at = {cid: i for i, cid in enumerate(counted)}
385
+ unknown = {}
386
+ if any(cid not in self.asked for cid in counted):
387
+ unknown["step"] = unknown["parent"] = "a call asked for in an earlier turn finished in this one"
388
+ if self.parent_unknown:
389
+ unknown.setdefault("parent", self.parent_unknown)
390
+ if any(self.conv_of.get(c) is not None and self.conv_of[c] not in at for c in counted):
391
+ unknown.setdefault("parent", "a call ran inside a tool call that was not counted")
392
+ steps = {}
393
+ calls = []
394
+ for i, cid in enumerate(counted):
395
+ call = {"id": "c%d" % (i + 1), "tool": self.requested[cid][0], "event": i}
396
+ if "step" not in unknown:
397
+ call["step"] = steps.setdefault(self.step_of.get(cid), len(steps) + 1)
398
+ if "parent" not in unknown:
399
+ parent = self.conv_of.get(cid)
400
+ call["parent"] = None if parent is None else "c%d" % (at[parent] + 1)
401
+ calls.append(call)
402
+ return {
403
+ "links": "full",
404
+ "known": [f for f in ("step", "parent") if f not in unknown],
405
+ "unknown": unknown,
406
+ "calls": calls,
407
+ }
408
+
409
+ def _saw_request(self, kind, body):
410
+ self.model_requests += 1
411
+ if len(body) > MAX_BODY:
412
+ return self._fail("a model request was larger than 2 MB, so it was forwarded without being read")
413
+ try:
414
+ request = json.loads(body or b"{}")
415
+ except ValueError:
416
+ return self._fail("a model request was not JSON")
417
+ if not isinstance(request, dict):
418
+ return self._fail("a model request was not a JSON object")
419
+ if request.get("tools") or request.get("functions"):
420
+ self.offered_tools = True
421
+ try:
422
+ history = _requests_in_history(kind, request)
423
+ results = _results_in_request(kind, request)
424
+ unrecorded = _unrecorded_in_history(kind, request)
425
+ except _Unreadable as exc:
426
+ return self._fail(str(exc))
427
+ self._saw_unrecorded(unrecorded)
428
+ for cid, name, args in history:
429
+ if name is None:
430
+ continue # its result has no readable request: the orphan rule decides
431
+ self._note(cid, name, args, override=True)
432
+ ids = [cid for cid, _ in results]
433
+ if len(ids) != len(set(ids)):
434
+ return self._fail("two tool results shared one id, so they cannot be told apart")
435
+ for cid, content in results:
436
+ if cid in self.waiting and cid not in self.requested:
437
+ self._note(cid, *self.waiting[cid])
438
+ self.results.setdefault(cid, content)
439
+ return self._conversation([cid for cid, _, _ in history] + ids)
440
+
441
+ def _saw_unrecorded(self, items):
442
+ for key, reason in items:
443
+ # Re-sent history from an earlier turn was already filed there.
444
+ if key and "item:" + key in self.already:
445
+ continue
446
+ if key:
447
+ self.unrecorded.append("item:" + key)
448
+ self._fail(reason)
449
+
450
+ def _saw_reply(self, kind, status, content_type, content_encoding, body, truncated, conv=None):
451
+ if status >= 400:
452
+ self.last_reply_failed = True # asks for no tool; cleared if a retry succeeds
453
+ return
454
+ self.last_reply_failed = False
455
+ if truncated:
456
+ return self._fail("a model reply was larger than 2 MB, so it was forwarded without being read")
457
+ try:
458
+ raw = _decoded(body, content_encoding)
459
+ if "text/event-stream" in (content_type or ""):
460
+ calls = _requests_in_stream(kind, raw)
461
+ unrecorded = _unrecorded_in_stream(kind, raw)
462
+ else:
463
+ reply = json.loads(raw or b"{}")
464
+ calls = _requests_in_reply(kind, reply) if isinstance(reply, dict) else []
465
+ unrecorded = _unrecorded_in_reply(kind, reply) if isinstance(reply, dict) else []
466
+ except _Unreadable as exc:
467
+ return self._fail(str(exc))
468
+ except (ValueError, OSError, zlib.error):
469
+ return self._fail("a model reply could not be read")
470
+ self._saw_unrecorded(unrecorded)
471
+ if any(name for _, name, _ in calls):
472
+ self.step += 1
473
+ for cid, name, args in calls:
474
+ if name is None:
475
+ self._fail("the model asked for a tool call with no tool name, which PreMan cannot read")
476
+ continue
477
+ # A reply never re-sends history, so an id seen before is a new call
478
+ # sharing an old id: its result could pair with the wrong call.
479
+ # Compared with earlier replies only, not with history: the agent can
480
+ # re-send this very reply as history before the proxy finishes
481
+ # reading the stream, so history may already hold it.
482
+ if cid in self.already:
483
+ self.reused_id = True
484
+ elif cid in self.asked:
485
+ self.reused_in_turn = True
486
+ self._note(cid, name, args)
487
+ self.asked.append(cid)
488
+ self.step_of.setdefault(cid, self.step)
489
+ self.conv_of.setdefault(cid, conv)
490
+
491
+ def _outcome(self):
492
+ # Compute events, orphans, and unanswered first
493
+ events = []
494
+ # Only ids the model asked for in a reply this turn: a call and result
495
+ # the agent wrote into its first request (examples) never ran.
496
+ for cid in self.order:
497
+ if cid in self.results and (cid in self.asked or cid in self.waiting) and cid not in self.already:
498
+ name, args = self.requested[cid]
499
+ events.append({"role": "tool_result", "tool_name": name, "tool_args": args, "content": self.results[cid]})
500
+ orphan_results = [cid for cid in self.results if cid not in self.requested and cid not in self.already]
501
+ unanswered = [self.requested[cid][0] for cid in self.asked if cid not in self.results and cid not in self.already]
502
+
503
+ # Update already with event ids and orphan ids (NOT unanswered)
504
+ event_cids = [
505
+ cid for cid in self.order
506
+ if cid in self.results and (cid in self.asked or cid in self.waiting) and cid not in self.already
507
+ ]
508
+ self._counted = list(event_cids)
509
+ self.waiting.update({cid: self.requested[cid] for cid in self.asked if cid not in self.results})
510
+ for cid in event_cids:
511
+ self.waiting.pop(cid, None)
512
+ self.already.update(event_cids)
513
+ self.already.update(orphan_results)
514
+ self.already.update(self.unrecorded)
515
+
516
+ # Choose the capture based on problems/conditions
517
+ if self.problem:
518
+ return events, _not_visible(self.problem)
519
+ if self.reused_id:
520
+ return events, _not_visible(
521
+ "the model reused a tool-call id from an earlier turn, "
522
+ "so this turn's calls cannot be told apart from earlier ones"
523
+ )
524
+ if self.reused_in_turn:
525
+ return events, _not_visible(
526
+ "the model asked for two tool calls with the same id in this turn, "
527
+ "so their results cannot be told apart"
528
+ )
529
+ if orphan_results:
530
+ return events, _not_visible("a tool result went back to the model for a call PreMan could not read")
531
+ if not self.model_requests:
532
+ return events, _not_visible(_NO_TRAFFIC)
533
+ if self.last_reply_failed:
534
+ return events, _not_visible(
535
+ "the model's last reply in this turn was an HTTP error, so the agent may have answered "
536
+ "without the tools it meant to call"
537
+ )
538
+ if unanswered:
539
+ return events, _not_visible(
540
+ "the model asked for %s, and the result never went back to the model, so whether it ran is unknown"
541
+ % ", ".join(sorted(set(unanswered)))
542
+ )
543
+ if not events and not self.offered_tools:
544
+ return events, _not_visible(_NO_TOOLS_OFFERED)
545
+ return events, {"state": "recorded", "source": "proxy", "watched": [], "missing": [], "reason": ""}
546
+
547
+
548
+ DEFAULT_UPSTREAM = {"openai": "https://api.openai.com/v1", "anthropic": "https://api.anthropic.com"}
549
+ ENV_VAR = {"openai": "OPENAI_BASE_URL", "anthropic": "ANTHROPIC_BASE_URL"}
550
+ TIMEOUT = float(os.environ.get("PREMAN_AGENT_TIMEOUT", "600"))
551
+ _HOP = {
552
+ "connection", "keep-alive", "proxy-connection", "proxy-authorization", "transfer-encoding",
553
+ "te", "trailer", "upgrade", "host", "content-length", "accept-encoding",
554
+ }
555
+
556
+
557
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
558
+ def redirect_request(self, *args, **kwargs):
559
+ return None # a 3xx goes back to the agent unchanged; credentials never follow it elsewhere
560
+
561
+
562
+ _opener = urllib.request.build_opener(_NoRedirect)
563
+
564
+
565
+ class _Server(http.server.ThreadingHTTPServer):
566
+ daemon_threads = True
567
+
568
+ def handle_error(self, request, client_address):
569
+ pass # nothing is ever printed: a traceback can carry a header or a token
570
+
571
+
572
+ def version() -> str:
573
+ """The CLI version this proxy shipped with, recorded on every turn (spec §6.0.3)."""
574
+ try:
575
+ return str(json.loads((Path(__file__).resolve().parent.parent / "package.json").read_text())["version"])
576
+ except Exception:
577
+ return "unknown"
578
+
579
+
580
+ class Proxy:
581
+ def __init__(self, upstream=None):
582
+ self.upstream = upstream or {
583
+ provider: (os.environ.get(var) or DEFAULT_UPSTREAM[provider]).rstrip("/")
584
+ for provider, var in ENV_VAR.items()
585
+ }
586
+ self._channels = {}
587
+ self._lock = threading.Lock()
588
+ self._server = _Server(("127.0.0.1", 0), _handler(self))
589
+ self._server.daemon_threads = True
590
+ self.port = self._server.server_address[1]
591
+ threading.Thread(target=self._server.serve_forever, name="preman-tool-proxy", daemon=True).start()
592
+
593
+ def channel(self) -> str:
594
+ token = secrets.token_hex(16)
595
+ with self._lock:
596
+ self._channels[token] = {"turn": None, "between": False, "reported": set(), "waiting": {}}
597
+ return token
598
+
599
+ def env(self, token: str) -> dict:
600
+ env = {var: "http://127.0.0.1:%d/%s/%s" % (self.port, token, provider) for provider, var in ENV_VAR.items()}
601
+ # An agent behind HTTP(S)_PROXY must still reach this local proxy directly.
602
+ hosts = [h.strip() for h in (os.environ.get("NO_PROXY") or os.environ.get("no_proxy") or "").split(",") if h.strip()]
603
+ hosts += [h for h in ("127.0.0.1", "localhost") if h not in hosts]
604
+ env["NO_PROXY"] = env["no_proxy"] = ",".join(hosts)
605
+ return env
606
+
607
+ def begin(self, token: str) -> None:
608
+ with self._lock:
609
+ channel = self._channels.get(token)
610
+ if channel is not None:
611
+ channel["turn"] = _Turn(already=channel["reported"], waiting=channel["waiting"])
612
+
613
+ def end(self, token: str):
614
+ with self._lock:
615
+ channel = self._channels.get(token)
616
+ if channel is None:
617
+ capture = _not_visible(_CHANNEL_CLOSED)
618
+ capture["trace"] = {"links": "order_only", "reason": _CHANNEL_CLOSED, "known": [], "unknown": {}, "calls": []}
619
+ capture["proxy_version"] = version()
620
+ return [], capture
621
+ turn, channel["turn"] = channel["turn"], None
622
+ between, channel["between"] = channel["between"], False
623
+ events, capture = (turn or _Turn(already=channel["reported"], waiting=channel["waiting"])).outcome()
624
+ if between and capture["state"] == "recorded":
625
+ capture = dict(
626
+ _not_visible("a model request arrived between turns, so it was not filed under any of them"),
627
+ trace=capture.get("trace"),
628
+ )
629
+ capture["proxy_version"] = version()
630
+ return events, capture
631
+
632
+ def drop(self, token: str) -> None:
633
+ with self._lock:
634
+ self._channels.pop(token, None)
635
+
636
+ def close(self) -> None:
637
+ self._server.shutdown()
638
+ self._server.server_close()
639
+
640
+ def _turn_for(self, token, kind):
641
+ with self._lock:
642
+ channel = self._channels.get(token)
643
+ if channel is None:
644
+ return None, False
645
+ if channel["turn"] is None and kind:
646
+ # Only a model request counts: a model list or a token count
647
+ # between turns carries no tool call.
648
+ channel["between"] = True
649
+ return channel["turn"], True
650
+
651
+
652
+ def _handler(proxy):
653
+ class Handler(http.server.BaseHTTPRequestHandler):
654
+ protocol_version = "HTTP/1.1"
655
+
656
+ def log_message(self, *args):
657
+ pass # the request line carries the channel token; nothing is logged
658
+
659
+ def _body(self):
660
+ if "chunked" in (self.headers.get("Transfer-Encoding") or "").lower():
661
+ data = bytearray()
662
+ while True:
663
+ size = int(self.rfile.readline().split(b";")[0].strip() or b"0", 16)
664
+ if size < 0:
665
+ raise ValueError("negative chunk size")
666
+ if not size:
667
+ self.rfile.readline()
668
+ return bytes(data)
669
+ data += self.rfile.read(size)
670
+ self.rfile.readline()
671
+ length = int(self.headers.get("Content-Length") or 0)
672
+ if length < 0:
673
+ raise ValueError("negative content length")
674
+ return self.rfile.read(length) if length else b""
675
+
676
+ def _refuse(self, status, text):
677
+ payload = json.dumps({"error": text}).encode()
678
+ self.send_response_only(status)
679
+ self.send_header("Content-Type", "application/json")
680
+ self.send_header("Content-Length", str(len(payload)))
681
+ self.send_header("Connection", "close") # the request body may be unread
682
+ self.end_headers()
683
+ self.wfile.write(payload)
684
+ self.close_connection = True
685
+
686
+ def _forward(self):
687
+ parts = self.path.split("/", 3) # ["", token, provider, rest]
688
+ if len(parts) < 3 or parts[2] not in ENV_VAR:
689
+ return self._refuse(404, "not a PreMan proxy address")
690
+ token, provider = parts[1], parts[2]
691
+ rest = "/" + parts[3] if len(parts) > 3 else ""
692
+ kind = kind_of(provider, rest)
693
+ turn, known = proxy._turn_for(token, kind)
694
+ if not known:
695
+ return self._refuse(404, "not a PreMan proxy address")
696
+ try:
697
+ body = self._body()
698
+ except ValueError:
699
+ return self._refuse(400, "malformed request body")
700
+ conv = None
701
+ if turn is not None and kind:
702
+ try:
703
+ conv = turn.saw_request(kind, body)
704
+ except Exception:
705
+ turn._fail("a model request could not be read")
706
+
707
+ headers = {k: v for k, v in self.headers.items() if k.lower() not in _HOP}
708
+ request = urllib.request.Request(
709
+ proxy.upstream[provider] + rest,
710
+ data=body if body or self.command in ("POST", "PUT", "PATCH") else None,
711
+ headers=headers,
712
+ method=self.command,
713
+ )
714
+ try:
715
+ reply = _opener.open(request, timeout=TIMEOUT)
716
+ except urllib.error.HTTPError as exc:
717
+ reply = exc # a 4xx/5xx reply still goes back to the agent unchanged
718
+ except OSError:
719
+ self.close_connection = True # unreachable: the agent sees a dropped connection
720
+ return
721
+
722
+ bodyless = reply.status < 200 or reply.status in (204, 304)
723
+ self.send_response_only(reply.status, reply.reason)
724
+ for name, value in reply.headers.items():
725
+ if name.lower() not in _HOP:
726
+ self.send_header(name, value)
727
+ if not bodyless:
728
+ self.send_header("Transfer-Encoding", "chunked")
729
+ self.end_headers()
730
+ copy = bytearray()
731
+ cut = False
732
+
733
+ def send(chunk):
734
+ self.wfile.write(b"%x\r\n%s\r\n" % (len(chunk), chunk))
735
+ self.wfile.flush()
736
+
737
+ # One chunk behind: the reply's last chunk reaches the agent only
738
+ # after the proxy has read the whole reply. Otherwise an SDK that
739
+ # acts on "[DONE]" can start the next request -- a sub-agent's, or
740
+ # the turn's end -- before the proxy knows what this reply asked for.
741
+ held = b""
742
+ try:
743
+ while not bodyless:
744
+ chunk = reply.read1(65536) if hasattr(reply, "read1") else reply.read(65536)
745
+ if not chunk:
746
+ break
747
+ if held:
748
+ send(held)
749
+ held = chunk
750
+ if len(copy) <= MAX_BODY:
751
+ copy += chunk
752
+ except (OSError, http.client.HTTPException):
753
+ cut = True
754
+ self.close_connection = True # the reply broke off, from either end
755
+ finally:
756
+ reply.close()
757
+ if turn is not None and kind:
758
+ try:
759
+ if cut:
760
+ turn._fail("a model reply was cut off before it ended")
761
+ turn.saw_reply(
762
+ kind,
763
+ reply.status,
764
+ reply.headers.get("Content-Type", ""),
765
+ reply.headers.get("Content-Encoding", ""),
766
+ bytes(copy),
767
+ len(copy) > MAX_BODY,
768
+ conv,
769
+ )
770
+ except Exception:
771
+ turn._fail("a model reply could not be read")
772
+ try:
773
+ if held:
774
+ send(held)
775
+ if not bodyless and not cut:
776
+ self.wfile.write(b"0\r\n\r\n")
777
+ except (OSError, http.client.HTTPException):
778
+ self.close_connection = True # the agent hung up
779
+
780
+ do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = _forward
781
+
782
+ return Handler
783
+
784
+
785
+ _shared = None
786
+ _shared_lock = threading.Lock()
787
+
788
+
789
+ def shared() -> Proxy:
790
+ """The one proxy for this process, started on first use."""
791
+ global _shared
792
+ with _shared_lock:
793
+ if _shared is None:
794
+ _shared = Proxy()
795
+ return _shared
796
+
797
+
798
+ def choose(proxied, reported=None):
799
+ """The proxy's account when it saw the turn; otherwise (it saw no traffic, its
800
+ channel closed, or no tools were offered) the agent's own report if that one
801
+ was recorded. One source per
802
+ turn, never both, so a call seen both ways is counted once."""
803
+ if reported is not None and proxied[1].get("state") == "recorded" and reported[1].get("state") == "recorded":
804
+ def calls(events):
805
+ return sum(
806
+ 1 for e in events or []
807
+ if isinstance(e, dict) and (e.get("role") == "tool_result" or (not e.get("role") and e.get("tool_name")))
808
+ )
809
+ if calls(reported[0]) > calls(proxied[0]):
810
+ capture = _not_visible("the agent reported more tool calls than PreMan's proxy saw")
811
+ capture["proxy_version"] = proxied[1].get("proxy_version", version())
812
+ if "trace" in proxied[1]:
813
+ capture["trace"] = proxied[1]["trace"]
814
+ return proxied[0], capture
815
+ if proxied[1].get("state") == "recorded" or reported is None:
816
+ return proxied
817
+ if reported[1].get("state") == "recorded" and proxied[1].get("reason") in _UNSEEN:
818
+ capture = dict(reported[1])
819
+ capture["proxy_version"] = proxied[1].get("proxy_version", version())
820
+ return reported[0], capture
821
+ # Neither recorded: keep the agent's own diagnostic next to the proxy's.
822
+ proxied_reason = proxied[1].get("reason") or ""
823
+ reported_reason = (reported[1].get("reason") or "") if reported else ""
824
+ if reported_reason and reported_reason != proxied_reason:
825
+ capture = dict(proxied[1])
826
+ capture["reason"] = "%s; %s" % (proxied_reason, reported_reason) if proxied_reason else reported_reason
827
+ return proxied[0], capture
828
+ return proxied