lm-deluge 0.0.67__py3-none-any.whl → 0.0.90__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.

Potentially problematic release.


This version of lm-deluge might be problematic. Click here for more details.

Files changed (108) hide show
  1. lm_deluge/__init__.py +1 -2
  2. lm_deluge/api_requests/anthropic.py +117 -22
  3. lm_deluge/api_requests/base.py +84 -11
  4. lm_deluge/api_requests/bedrock.py +30 -6
  5. lm_deluge/api_requests/chat_reasoning.py +4 -0
  6. lm_deluge/api_requests/gemini.py +166 -20
  7. lm_deluge/api_requests/openai.py +145 -25
  8. lm_deluge/batches.py +15 -45
  9. lm_deluge/client.py +309 -50
  10. lm_deluge/config.py +15 -3
  11. lm_deluge/models/__init__.py +14 -1
  12. lm_deluge/models/anthropic.py +29 -14
  13. lm_deluge/models/arcee.py +16 -0
  14. lm_deluge/models/deepseek.py +36 -4
  15. lm_deluge/models/google.py +42 -0
  16. lm_deluge/models/grok.py +24 -0
  17. lm_deluge/models/kimi.py +36 -0
  18. lm_deluge/models/minimax.py +18 -0
  19. lm_deluge/models/openai.py +100 -0
  20. lm_deluge/models/openrouter.py +133 -7
  21. lm_deluge/models/together.py +11 -0
  22. lm_deluge/models/zai.py +50 -0
  23. lm_deluge/pipelines/gepa/__init__.py +95 -0
  24. lm_deluge/pipelines/gepa/core.py +354 -0
  25. lm_deluge/pipelines/gepa/docs/samples.py +705 -0
  26. lm_deluge/pipelines/gepa/examples/01_synthetic_keywords.py +140 -0
  27. lm_deluge/pipelines/gepa/examples/02_gsm8k_math.py +261 -0
  28. lm_deluge/pipelines/gepa/examples/03_hotpotqa_multihop.py +300 -0
  29. lm_deluge/pipelines/gepa/examples/04_batch_classification.py +271 -0
  30. lm_deluge/pipelines/gepa/examples/simple_qa.py +129 -0
  31. lm_deluge/pipelines/gepa/optimizer.py +435 -0
  32. lm_deluge/pipelines/gepa/proposer.py +235 -0
  33. lm_deluge/pipelines/gepa/util.py +165 -0
  34. lm_deluge/{llm_tools → pipelines}/score.py +2 -2
  35. lm_deluge/{llm_tools → pipelines}/translate.py +5 -3
  36. lm_deluge/prompt.py +537 -88
  37. lm_deluge/request_context.py +7 -2
  38. lm_deluge/server/__init__.py +24 -0
  39. lm_deluge/server/__main__.py +144 -0
  40. lm_deluge/server/adapters.py +369 -0
  41. lm_deluge/server/app.py +388 -0
  42. lm_deluge/server/auth.py +71 -0
  43. lm_deluge/server/model_policy.py +215 -0
  44. lm_deluge/server/models_anthropic.py +172 -0
  45. lm_deluge/server/models_openai.py +175 -0
  46. lm_deluge/tool/__init__.py +1130 -0
  47. lm_deluge/tool/builtin/anthropic/__init__.py +300 -0
  48. lm_deluge/tool/builtin/anthropic/bash.py +0 -0
  49. lm_deluge/tool/builtin/anthropic/computer_use.py +0 -0
  50. lm_deluge/tool/builtin/gemini.py +59 -0
  51. lm_deluge/tool/builtin/openai.py +74 -0
  52. lm_deluge/tool/cua/__init__.py +173 -0
  53. lm_deluge/tool/cua/actions.py +148 -0
  54. lm_deluge/tool/cua/base.py +27 -0
  55. lm_deluge/tool/cua/batch.py +215 -0
  56. lm_deluge/tool/cua/converters.py +466 -0
  57. lm_deluge/tool/cua/kernel.py +702 -0
  58. lm_deluge/tool/cua/trycua.py +989 -0
  59. lm_deluge/tool/prefab/__init__.py +45 -0
  60. lm_deluge/tool/prefab/batch_tool.py +156 -0
  61. lm_deluge/tool/prefab/docs.py +1119 -0
  62. lm_deluge/tool/prefab/email.py +294 -0
  63. lm_deluge/tool/prefab/filesystem.py +1711 -0
  64. lm_deluge/tool/prefab/full_text_search/__init__.py +285 -0
  65. lm_deluge/tool/prefab/full_text_search/tantivy_index.py +396 -0
  66. lm_deluge/tool/prefab/memory.py +458 -0
  67. lm_deluge/tool/prefab/otc/__init__.py +165 -0
  68. lm_deluge/tool/prefab/otc/executor.py +281 -0
  69. lm_deluge/tool/prefab/otc/parse.py +188 -0
  70. lm_deluge/tool/prefab/random.py +212 -0
  71. lm_deluge/tool/prefab/rlm/__init__.py +296 -0
  72. lm_deluge/tool/prefab/rlm/executor.py +349 -0
  73. lm_deluge/tool/prefab/rlm/parse.py +144 -0
  74. lm_deluge/tool/prefab/sandbox/__init__.py +19 -0
  75. lm_deluge/tool/prefab/sandbox/daytona_sandbox.py +483 -0
  76. lm_deluge/tool/prefab/sandbox/docker_sandbox.py +609 -0
  77. lm_deluge/tool/prefab/sandbox/fargate_sandbox.py +546 -0
  78. lm_deluge/tool/prefab/sandbox/modal_sandbox.py +469 -0
  79. lm_deluge/tool/prefab/sandbox/seatbelt_sandbox.py +827 -0
  80. lm_deluge/tool/prefab/sheets.py +385 -0
  81. lm_deluge/tool/prefab/skills.py +0 -0
  82. lm_deluge/tool/prefab/subagents.py +233 -0
  83. lm_deluge/tool/prefab/todos.py +342 -0
  84. lm_deluge/tool/prefab/tool_search.py +169 -0
  85. lm_deluge/tool/prefab/web_search.py +199 -0
  86. lm_deluge/tracker.py +16 -13
  87. lm_deluge/util/schema.py +412 -0
  88. lm_deluge/warnings.py +8 -0
  89. {lm_deluge-0.0.67.dist-info → lm_deluge-0.0.90.dist-info}/METADATA +23 -9
  90. lm_deluge-0.0.90.dist-info/RECORD +132 -0
  91. lm_deluge/built_in_tools/anthropic/__init__.py +0 -128
  92. lm_deluge/built_in_tools/openai.py +0 -28
  93. lm_deluge/presets/cerebras.py +0 -17
  94. lm_deluge/presets/meta.py +0 -13
  95. lm_deluge/tool.py +0 -849
  96. lm_deluge-0.0.67.dist-info/RECORD +0 -72
  97. lm_deluge/{llm_tools → pipelines}/__init__.py +1 -1
  98. /lm_deluge/{llm_tools → pipelines}/classify.py +0 -0
  99. /lm_deluge/{llm_tools → pipelines}/extract.py +0 -0
  100. /lm_deluge/{llm_tools → pipelines}/locate.py +0 -0
  101. /lm_deluge/{llm_tools → pipelines}/ocr.py +0 -0
  102. /lm_deluge/{built_in_tools/anthropic/bash.py → skills/anthropic.py} +0 -0
  103. /lm_deluge/{built_in_tools/anthropic/computer_use.py → skills/compat.py} +0 -0
  104. /lm_deluge/{built_in_tools → tool/builtin}/anthropic/editor.py +0 -0
  105. /lm_deluge/{built_in_tools → tool/builtin}/base.py +0 -0
  106. {lm_deluge-0.0.67.dist-info → lm_deluge-0.0.90.dist-info}/WHEEL +0 -0
  107. {lm_deluge-0.0.67.dist-info → lm_deluge-0.0.90.dist-info}/licenses/LICENSE +0 -0
  108. {lm_deluge-0.0.67.dist-info → lm_deluge-0.0.90.dist-info}/top_level.txt +0 -0
lm_deluge/prompt.py CHANGED
@@ -23,16 +23,95 @@ CachePattern = Literal[
23
23
  # 1. Low-level content blocks – either text or an image #
24
24
  ###############################################################################
25
25
  Role = Literal["system", "user", "assistant", "tool"]
26
+ SignatureProvider = Literal["anthropic", "gemini"]
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class ThoughtSignature:
31
+ value: str
32
+ provider: SignatureProvider | None = None
33
+
34
+ def for_provider(self, provider: SignatureProvider) -> str | None:
35
+ if self.provider is None or self.provider == provider:
36
+ return self.value
37
+ return None
38
+
39
+
40
+ ThoughtSignatureLike: TypeAlias = ThoughtSignature | str
41
+
42
+
43
+ def _normalize_signature(
44
+ signature: ThoughtSignatureLike | None,
45
+ *,
46
+ provider: SignatureProvider | None = None,
47
+ ) -> ThoughtSignature | None:
48
+ if signature is None:
49
+ return None
50
+ if isinstance(signature, ThoughtSignature):
51
+ if provider is not None and signature.provider is None:
52
+ return ThoughtSignature(signature.value, provider)
53
+ return signature
54
+ return ThoughtSignature(signature, provider)
55
+
56
+
57
+ def _signature_for_provider(
58
+ signature: ThoughtSignatureLike | None, provider: SignatureProvider
59
+ ) -> str | None:
60
+ if signature is None:
61
+ return None
62
+ if isinstance(signature, ThoughtSignature):
63
+ return signature.for_provider(provider)
64
+ return signature
65
+
66
+
67
+ def _signature_value(signature: ThoughtSignatureLike | None) -> str | None:
68
+ if signature is None:
69
+ return None
70
+ if isinstance(signature, ThoughtSignature):
71
+ return signature.value
72
+ return signature
73
+
74
+
75
+ def _serialize_signature(signature: ThoughtSignatureLike | None) -> str | dict | None:
76
+ if signature is None:
77
+ return None
78
+ if isinstance(signature, ThoughtSignature):
79
+ if signature.provider is None:
80
+ return signature.value
81
+ return {"value": signature.value, "provider": signature.provider}
82
+ return signature
83
+
84
+
85
+ def _deserialize_signature(payload: str | dict | None) -> ThoughtSignature | None:
86
+ if payload is None:
87
+ return None
88
+ if isinstance(payload, dict):
89
+ value = payload.get("value")
90
+ provider = payload.get("provider")
91
+ if isinstance(value, str):
92
+ if provider in ("anthropic", "gemini"):
93
+ return ThoughtSignature(value, provider)
94
+ return ThoughtSignature(value)
95
+ return None
96
+ if isinstance(payload, str):
97
+ return ThoughtSignature(payload)
98
+ return None
26
99
 
27
100
 
28
101
  @dataclass(slots=True)
29
102
  class Text:
30
103
  text: str
31
104
  type: str = field(init=False, default="text")
105
+ # for gemini 3 - thought signatures to maintain reasoning context
106
+ thought_signature: ThoughtSignatureLike | None = None
107
+
108
+ def __post_init__(self) -> None:
109
+ self.thought_signature = _normalize_signature(self.thought_signature)
32
110
 
33
111
  @property
34
112
  def fingerprint(self) -> str:
35
- return xxhash.xxh64(self.text.encode()).hexdigest()
113
+ signature = _signature_value(self.thought_signature) or ""
114
+ return xxhash.xxh64(f"{self.text}:{signature}".encode()).hexdigest()
36
115
 
37
116
  # ── provider-specific emission ────────────────────────────────────────────
38
117
  def oa_chat(self) -> dict | str: # OpenAI Chat Completions
@@ -45,7 +124,11 @@ class Text:
45
124
  return {"type": "text", "text": self.text}
46
125
 
47
126
  def gemini(self) -> dict:
48
- return {"text": self.text}
127
+ result = {"text": self.text}
128
+ signature = _signature_for_provider(self.thought_signature, "gemini")
129
+ if signature is not None:
130
+ result["thoughtSignature"] = signature
131
+ return result
49
132
 
50
133
  def mistral(self) -> dict:
51
134
  return {"type": "text", "text": self.text}
@@ -61,6 +144,11 @@ class ToolCall:
61
144
  built_in: bool = False
62
145
  built_in_type: str | None = None
63
146
  extra_body: dict | None = None
147
+ # for gemini 3 - thought signatures to maintain reasoning context
148
+ thought_signature: ThoughtSignatureLike | None = None
149
+
150
+ def __post_init__(self) -> None:
151
+ self.thought_signature = _normalize_signature(self.thought_signature)
64
152
 
65
153
  @property
66
154
  def fingerprint(self) -> str:
@@ -93,7 +181,11 @@ class ToolCall:
93
181
  }
94
182
 
95
183
  def gemini(self) -> dict:
96
- return {"functionCall": {"name": self.name, "args": self.arguments}}
184
+ result = {"functionCall": {"name": self.name, "args": self.arguments}}
185
+ signature = _signature_for_provider(self.thought_signature, "gemini")
186
+ if signature is not None:
187
+ result["thoughtSignature"] = signature # type: ignore
188
+ return result
97
189
 
98
190
  def mistral(self) -> dict:
99
191
  return {
@@ -198,6 +290,8 @@ class ToolResult:
198
290
  "call_id": self.tool_call_id,
199
291
  }
200
292
  if self.built_in_type == "computer_call":
293
+ # OpenAI expects "computer_call_output" for the result type
294
+ result["type"] = "computer_call_output"
201
295
  result["output"] = output_data.get("output", {})
202
296
  if "acknowledged_safety_checks" in output_data:
203
297
  result["acknowledged_safety_checks"] = output_data[
@@ -230,15 +324,41 @@ class ToolResult:
230
324
  raise ValueError("unsupported self.result type")
231
325
 
232
326
  def gemini(self) -> dict:
233
- if not isinstance(self.result, str):
234
- raise ValueError("can't handle content blocks for gemini yet")
235
- return {
236
- "functionResponse": {
237
- "name": self.tool_call_id, # Gemini uses name field for ID
238
- "response": {"result": self.result},
239
- }
327
+ # Build the function response
328
+ func_response: dict = {
329
+ "name": self.tool_call_id, # Gemini uses name field for ID
240
330
  }
241
331
 
332
+ # Handle different result types
333
+ if isinstance(self.result, str):
334
+ func_response["response"] = {"result": self.result}
335
+ elif isinstance(self.result, dict):
336
+ # Check for Gemini computer use format with inline screenshot
337
+ if self.built_in_type == "gemini_computer_use":
338
+ # Gemini CU expects response dict with optional inline_data parts
339
+ func_response["response"] = self.result.get("response", {})
340
+ # Include inline data (screenshot) if present
341
+ if "inline_data" in self.result:
342
+ func_response["parts"] = [
343
+ {
344
+ "inlineData": {
345
+ "mimeType": self.result["inline_data"].get(
346
+ "mime_type", "image/png"
347
+ ),
348
+ "data": self.result["inline_data"]["data"],
349
+ }
350
+ }
351
+ ]
352
+ else:
353
+ func_response["response"] = self.result
354
+ elif isinstance(self.result, list):
355
+ # Handle content blocks (images, etc.) - not yet implemented
356
+ raise ValueError("can't handle content blocks for gemini yet")
357
+ else:
358
+ func_response["response"] = {"result": str(self.result)}
359
+
360
+ return {"functionResponse": func_response}
361
+
242
362
  def mistral(self) -> dict:
243
363
  return {
244
364
  "type": "tool_result",
@@ -253,6 +373,12 @@ class Thinking:
253
373
  type: str = field(init=False, default="thinking")
254
374
  # for openai - to keep conversation chain
255
375
  raw_payload: dict | None = None
376
+ # for gemini 3 - thought signatures to maintain reasoning context
377
+ thought_signature: ThoughtSignatureLike | None = None
378
+ summary: str | None = None # to differentiate summary text from actual content
379
+
380
+ def __post_init__(self) -> None:
381
+ self.thought_signature = _normalize_signature(self.thought_signature)
256
382
 
257
383
  @property
258
384
  def fingerprint(self) -> str:
@@ -267,10 +393,20 @@ class Thinking:
267
393
  return {"type": "reasoning", "content": self.content}
268
394
 
269
395
  def anthropic(self) -> dict: # Anthropic Messages
270
- return {"type": "thinking", "thinking": self.content}
396
+ if self.raw_payload:
397
+ return dict(self.raw_payload)
398
+ result = {"type": "thinking", "thinking": self.content}
399
+ signature = _signature_for_provider(self.thought_signature, "anthropic")
400
+ if signature is not None:
401
+ result["signature"] = signature
402
+ return result
271
403
 
272
404
  def gemini(self) -> dict:
273
- return {"text": f"[Thinking: {self.content}]"}
405
+ result = {"text": f"[Thinking: {self.content}]"}
406
+ signature = _signature_for_provider(self.thought_signature, "gemini")
407
+ if signature is not None:
408
+ result["thoughtSignature"] = signature
409
+ return result
274
410
 
275
411
  def mistral(self) -> dict:
276
412
  return {"type": "text", "text": f"[Thinking: {self.content}]"}
@@ -341,10 +477,15 @@ class Message:
341
477
  # return {"type": "file", "tag": f"<File ({size} bytes)>"}
342
478
  # return repr(value)
343
479
 
344
- def to_log(self) -> dict:
480
+ def to_log(self, *, preserve_media: bool = False) -> dict:
345
481
  """
346
482
  Return a JSON-serialisable dict that fully captures the message.
483
+
484
+ Args:
485
+ preserve_media: If True, store full base64-encoded bytes for images and files.
486
+ If False (default), replace with placeholder tags.
347
487
  """
488
+ import base64
348
489
 
349
490
  def _json_safe(value):
350
491
  if isinstance(value, (str, int, float, bool)) or value is None:
@@ -366,22 +507,52 @@ class Message:
366
507
  content_blocks: list[dict] = []
367
508
  for p in self.parts:
368
509
  if isinstance(p, Text):
369
- content_blocks.append({"type": "text", "text": p.text})
370
- elif isinstance(p, Image): # Image – redact the bytes, keep a hint
371
- w, h = p.size
372
- content_blocks.append({"type": "image", "tag": f"<Image ({w}×{h})>"})
373
- elif isinstance(p, File): # File – redact the bytes, keep a hint
374
- size = p.size
375
- content_blocks.append({"type": "file", "tag": f"<File ({size} bytes)>"})
510
+ text_block: dict = {"type": "text", "text": p.text}
511
+ signature = _serialize_signature(p.thought_signature)
512
+ if signature is not None:
513
+ text_block["thought_signature"] = signature
514
+ content_blocks.append(text_block)
515
+ elif isinstance(p, Image):
516
+ if preserve_media:
517
+ content_blocks.append(
518
+ {
519
+ "type": "image",
520
+ "data": base64.b64encode(p._bytes()).decode("ascii"),
521
+ "media_type": p.media_type,
522
+ "detail": p.detail,
523
+ }
524
+ )
525
+ else:
526
+ w, h = p.size
527
+ content_blocks.append(
528
+ {"type": "image", "tag": f"<Image ({w}×{h})>"}
529
+ )
530
+ elif isinstance(p, File):
531
+ if preserve_media:
532
+ content_blocks.append(
533
+ {
534
+ "type": "file",
535
+ "data": base64.b64encode(p._bytes()).decode("ascii"),
536
+ "media_type": p.media_type,
537
+ "filename": p.filename,
538
+ }
539
+ )
540
+ else:
541
+ size = p.size
542
+ content_blocks.append(
543
+ {"type": "file", "tag": f"<File ({size} bytes)>"}
544
+ )
376
545
  elif isinstance(p, ToolCall):
377
- content_blocks.append(
378
- {
379
- "type": "tool_call",
380
- "id": p.id,
381
- "name": p.name,
382
- "arguments": _json_safe(p.arguments),
383
- }
384
- )
546
+ tool_call_block = {
547
+ "type": "tool_call",
548
+ "id": p.id,
549
+ "name": p.name,
550
+ "arguments": _json_safe(p.arguments),
551
+ }
552
+ signature = _serialize_signature(p.thought_signature)
553
+ if signature is not None:
554
+ tool_call_block["thought_signature"] = signature
555
+ content_blocks.append(tool_call_block)
385
556
  elif isinstance(p, ToolResult):
386
557
  content_blocks.append(
387
558
  {
@@ -391,38 +562,82 @@ class Message:
391
562
  }
392
563
  )
393
564
  elif isinstance(p, Thinking):
394
- content_blocks.append({"type": "thinking", "content": p.content})
565
+ thinking_block: dict = {"type": "thinking", "content": p.content}
566
+ signature = _serialize_signature(p.thought_signature)
567
+ if signature is not None:
568
+ thinking_block["thought_signature"] = signature
569
+ content_blocks.append(thinking_block)
395
570
 
396
571
  return {"role": self.role, "content": content_blocks}
397
572
 
398
573
  @classmethod
399
574
  def from_log(cls, data: dict) -> "Message":
400
575
  """Re-hydrate a Message previously produced by `to_log()`."""
401
- # DEBUG: Track when from_log is called
402
- # print(f"DEBUG: Message.from_log called for {data['role']} message with {len(data['content'])} content blocks")
576
+ import base64
577
+
403
578
  role: Role = data["role"]
404
579
  parts: list[Part] = []
405
580
 
406
581
  for p in data["content"]:
407
582
  if p["type"] == "text":
408
- parts.append(Text(p["text"]))
583
+ parts.append(
584
+ Text(
585
+ p["text"],
586
+ thought_signature=_deserialize_signature(
587
+ p.get("thought_signature")
588
+ ),
589
+ )
590
+ )
409
591
  elif p["type"] == "image":
410
- # We only stored a placeholder tag; rehydrate as inert text to avoid byte access.
411
- # print(f"DEBUG: Message.from_log creating Text placeholder for image: {p['tag']}")
412
- parts.append(Text(p["tag"]))
592
+ if "data" in p:
593
+ # Full image data was preserved
594
+ parts.append(
595
+ Image(
596
+ data=base64.b64decode(p["data"]),
597
+ media_type=p.get("media_type"),
598
+ detail=p.get("detail", "auto"),
599
+ )
600
+ )
601
+ else:
602
+ # Placeholder tag only
603
+ parts.append(Text(p["tag"]))
413
604
  elif p["type"] == "file":
414
- # We only stored a placeholder tag; rehydrate as inert text to avoid byte access.
415
- parts.append(Text(p["tag"]))
605
+ if "data" in p:
606
+ # Full file data was preserved
607
+ parts.append(
608
+ File(
609
+ data=base64.b64decode(p["data"]),
610
+ media_type=p.get("media_type"),
611
+ filename=p.get("filename"),
612
+ )
613
+ )
614
+ else:
615
+ # Placeholder tag only
616
+ parts.append(Text(p["tag"]))
416
617
  elif p["type"] == "tool_call":
417
618
  parts.append(
418
- ToolCall(id=p["id"], name=p["name"], arguments=p["arguments"])
619
+ ToolCall(
620
+ id=p["id"],
621
+ name=p["name"],
622
+ arguments=p["arguments"],
623
+ thought_signature=_deserialize_signature(
624
+ p.get("thought_signature")
625
+ ),
626
+ )
419
627
  )
420
628
  elif p["type"] == "tool_result":
421
629
  parts.append(
422
630
  ToolResult(tool_call_id=p["tool_call_id"], result=p["result"])
423
631
  )
424
632
  elif p["type"] == "thinking":
425
- parts.append(Thinking(content=p["content"]))
633
+ parts.append(
634
+ Thinking(
635
+ content=p["content"],
636
+ thought_signature=_deserialize_signature(
637
+ p.get("thought_signature")
638
+ ),
639
+ )
640
+ )
426
641
  else:
427
642
  raise ValueError(f"Unknown part type {p['type']!r}")
428
643
 
@@ -753,7 +968,15 @@ class Message:
753
968
  # Anthropic: system message is *not* in the list
754
969
  if self.role == "system":
755
970
  raise ValueError("Anthropic keeps system outside message list")
756
- content = [p.anthropic() for p in self.parts]
971
+ content: list[dict] = []
972
+ for part in self.parts:
973
+ if isinstance(part, Thinking) and part.raw_payload is None:
974
+ signature = _signature_for_provider(part.thought_signature, "anthropic")
975
+ if signature is None:
976
+ continue
977
+ content.append(part.anthropic())
978
+ if not content:
979
+ content = [{"type": "text", "text": ""}]
757
980
  # Shortcut: single text becomes a bare string
758
981
  if len(content) == 1 and content[0].get("type") == "text":
759
982
  content = content[0]["text"]
@@ -848,14 +1071,16 @@ class Conversation:
848
1071
  if content is None:
849
1072
  return parts
850
1073
  if isinstance(content, str):
851
- parts.append(Text(content))
1074
+ if content.strip():
1075
+ parts.append(Text(content))
852
1076
  return parts
853
1077
 
854
1078
  for block in content:
855
1079
  block_type = block.get("type")
856
1080
  if block_type in text_types:
857
1081
  text_value = block.get("text") or block.get(block_type) or ""
858
- parts.append(Text(text_value))
1082
+ if text_value.strip():
1083
+ parts.append(Text(text_value))
859
1084
  elif block_type in image_types:
860
1085
  parts.append(_to_image_from_url(block))
861
1086
  elif block_type in file_types:
@@ -1001,7 +1226,8 @@ class Conversation:
1001
1226
  )
1002
1227
  )
1003
1228
 
1004
- conversation_messages.append(Message(mapped_role, parts))
1229
+ if parts:
1230
+ conversation_messages.append(Message(mapped_role, parts))
1005
1231
 
1006
1232
  return cls(conversation_messages)
1007
1233
 
@@ -1079,7 +1305,9 @@ class Conversation:
1079
1305
  return result_parts
1080
1306
 
1081
1307
  def _anthropic_content_to_parts(
1082
- role: Role, content: str | list[dict] | None
1308
+ role: Role,
1309
+ content: str | list[dict] | None,
1310
+ signature_state: dict[str, ThoughtSignature | None] | None = None,
1083
1311
  ) -> list[Part]:
1084
1312
  parts: list[Part] = []
1085
1313
  if content is None:
@@ -1108,15 +1336,38 @@ class Conversation:
1108
1336
  raise ValueError("Anthropic tool_use block missing id")
1109
1337
  name = block.get("name") or "tool"
1110
1338
  arguments = block.get("input") or {}
1339
+ tool_call = ToolCall(
1340
+ id=tool_id,
1341
+ name=name,
1342
+ arguments=arguments
1343
+ if isinstance(arguments, dict)
1344
+ else {"value": arguments},
1345
+ )
1346
+ if signature_state is not None:
1347
+ pending_signature = signature_state.get("pending")
1348
+ if pending_signature:
1349
+ tool_call.thought_signature = pending_signature
1350
+ signature_state["pending"] = None
1351
+ parts.append(tool_call)
1352
+ elif block_type == "redacted_thinking":
1111
1353
  parts.append(
1112
- ToolCall(
1113
- id=tool_id,
1114
- name=name,
1115
- arguments=arguments
1116
- if isinstance(arguments, dict)
1117
- else {"value": arguments},
1354
+ Thinking(content=block.get("data", ""), raw_payload=block)
1355
+ )
1356
+ elif block_type == "thinking":
1357
+ thinking_content = block.get("thinking", "")
1358
+ signature = _normalize_signature(
1359
+ block.get("signature"),
1360
+ provider="anthropic",
1361
+ )
1362
+ parts.append(
1363
+ Thinking(
1364
+ content=thinking_content,
1365
+ raw_payload=block,
1366
+ thought_signature=signature,
1118
1367
  )
1119
1368
  )
1369
+ if signature_state is not None and signature is not None:
1370
+ signature_state["pending"] = signature
1120
1371
  elif block_type == "tool_result":
1121
1372
  tool_use_id = block.get("tool_use_id")
1122
1373
  if tool_use_id is None:
@@ -1126,9 +1377,6 @@ class Conversation:
1126
1377
  result = _anthropic_tool_result_content(block.get("content"))
1127
1378
  tool_result = ToolResult(tool_call_id=tool_use_id, result=result)
1128
1379
  parts.append(tool_result)
1129
- elif block_type == "thinking":
1130
- thinking_content = block.get("thinking", "")
1131
- parts.append(Thinking(content=thinking_content, raw_payload=block))
1132
1380
  else:
1133
1381
  parts.append(Text(json.dumps(block)))
1134
1382
  return parts
@@ -1158,6 +1406,9 @@ class Conversation:
1158
1406
  content = message.get("content")
1159
1407
  if isinstance(content, list):
1160
1408
  buffer_parts: list[Part] = []
1409
+ signature_state: None | dict[str, ThoughtSignature | None] = (
1410
+ {"pending": None} if base_role == "assistant" else None
1411
+ )
1161
1412
  for block in content:
1162
1413
  block_type = block.get("type")
1163
1414
  if block_type == "tool_result":
@@ -1179,7 +1430,11 @@ class Conversation:
1179
1430
  )
1180
1431
  )
1181
1432
  else:
1182
- block_parts = _anthropic_content_to_parts(base_role, [block])
1433
+ block_parts = _anthropic_content_to_parts(
1434
+ base_role,
1435
+ [block],
1436
+ signature_state=signature_state,
1437
+ )
1183
1438
  buffer_parts.extend(block_parts)
1184
1439
 
1185
1440
  if buffer_parts:
@@ -1192,14 +1447,24 @@ class Conversation:
1192
1447
 
1193
1448
  @classmethod
1194
1449
  def from_unknown(
1195
- cls, messages: list[dict], *, system: str | list[dict] | None = None
1450
+ cls, messages: list[dict] | dict, *, system: str | list[dict] | None = None
1196
1451
  ) -> tuple["Conversation", str]:
1197
1452
  """Attempt to convert provider-formatted messages without knowing the provider.
1198
1453
 
1199
1454
  Returns the parsed conversation together with the provider label that succeeded
1200
- ("openai" or "anthropic").
1455
+ ("openai", "anthropic", or "log").
1201
1456
  """
1202
1457
 
1458
+ # Check if input is in log format (output from to_log())
1459
+ if isinstance(messages, dict) and "messages" in messages:
1460
+ return cls.from_log(messages), "log"
1461
+
1462
+ # Ensure messages is a list for provider detection
1463
+ if not isinstance(messages, list):
1464
+ raise ValueError(
1465
+ "messages must be a list of dicts or a dict with 'messages' key"
1466
+ )
1467
+
1203
1468
  def _detect_provider() -> str:
1204
1469
  has_openai_markers = False
1205
1470
  has_anthropic_markers = False
@@ -1330,14 +1595,14 @@ class Conversation:
1330
1595
  # For assistant messages, extract computer calls as separate items
1331
1596
  text_parts = []
1332
1597
  for p in m.parts:
1333
- if isinstance(p, ToolCall) and p.name.startswith("_computer_"):
1598
+ if isinstance(p, ToolCall) and p.built_in_type == "computer_call":
1334
1599
  # Computer calls become separate items in the input array
1335
- action_type = p.name.replace("_computer_", "")
1600
+ # p.arguments already contains the full action dict with "type"
1336
1601
  input_items.append(
1337
1602
  {
1338
1603
  "type": "computer_call",
1339
1604
  "call_id": p.id,
1340
- "action": {"type": action_type, **p.arguments},
1605
+ "action": p.arguments,
1341
1606
  }
1342
1607
  )
1343
1608
  elif isinstance(p, Text):
@@ -1511,36 +1776,68 @@ class Conversation:
1511
1776
  hasher.update(json.dumps([m.fingerprint for m in self.messages]).encode())
1512
1777
  return hasher.hexdigest()
1513
1778
 
1514
- def to_log(self) -> dict:
1779
+ def to_log(self, *, preserve_media: bool = False) -> dict:
1515
1780
  """
1516
1781
  Return a JSON-serialisable dict that fully captures the conversation.
1782
+
1783
+ Args:
1784
+ preserve_media: If True, store full base64-encoded bytes for images and files.
1785
+ If False (default), replace with placeholder tags.
1517
1786
  """
1787
+ import base64
1788
+
1518
1789
  serialized: list[dict] = []
1519
1790
 
1520
1791
  for msg in self.messages:
1521
1792
  content_blocks: list[dict] = []
1522
1793
  for p in msg.parts:
1523
1794
  if isinstance(p, Text):
1524
- content_blocks.append({"type": "text", "text": p.text})
1525
- elif isinstance(p, Image): # Image – redact the bytes, keep a hint
1526
- w, h = p.size
1527
- content_blocks.append(
1528
- {"type": "image", "tag": f"<Image ({w}×{h})>"}
1529
- )
1530
- elif isinstance(p, File): # File – redact the bytes, keep a hint
1531
- size = p.size
1532
- content_blocks.append(
1533
- {"type": "file", "tag": f"<File ({size} bytes)>"}
1534
- )
1795
+ text_block: dict = {"type": "text", "text": p.text}
1796
+ signature = _serialize_signature(p.thought_signature)
1797
+ if signature is not None:
1798
+ text_block["thought_signature"] = signature
1799
+ content_blocks.append(text_block)
1800
+ elif isinstance(p, Image):
1801
+ if preserve_media:
1802
+ content_blocks.append(
1803
+ {
1804
+ "type": "image",
1805
+ "data": base64.b64encode(p._bytes()).decode("ascii"),
1806
+ "media_type": p.media_type,
1807
+ "detail": p.detail,
1808
+ }
1809
+ )
1810
+ else:
1811
+ w, h = p.size
1812
+ content_blocks.append(
1813
+ {"type": "image", "tag": f"<Image ({w}×{h})>"}
1814
+ )
1815
+ elif isinstance(p, File):
1816
+ if preserve_media:
1817
+ content_blocks.append(
1818
+ {
1819
+ "type": "file",
1820
+ "data": base64.b64encode(p._bytes()).decode("ascii"),
1821
+ "media_type": p.media_type,
1822
+ "filename": p.filename,
1823
+ }
1824
+ )
1825
+ else:
1826
+ size = p.size
1827
+ content_blocks.append(
1828
+ {"type": "file", "tag": f"<File ({size} bytes)>"}
1829
+ )
1535
1830
  elif isinstance(p, ToolCall):
1536
- content_blocks.append(
1537
- {
1538
- "type": "tool_call",
1539
- "id": p.id,
1540
- "name": p.name,
1541
- "arguments": p.arguments,
1542
- }
1543
- )
1831
+ tool_call_block = {
1832
+ "type": "tool_call",
1833
+ "id": p.id,
1834
+ "name": p.name,
1835
+ "arguments": p.arguments,
1836
+ }
1837
+ signature = _serialize_signature(p.thought_signature)
1838
+ if signature is not None:
1839
+ tool_call_block["thought_signature"] = signature
1840
+ content_blocks.append(tool_call_block)
1544
1841
  elif isinstance(p, ToolResult):
1545
1842
  content_blocks.append(
1546
1843
  {
@@ -1552,14 +1849,125 @@ class Conversation:
1552
1849
  }
1553
1850
  )
1554
1851
  elif isinstance(p, Thinking):
1555
- content_blocks.append({"type": "thinking", "content": p.content})
1852
+ thinking_block: dict = {"type": "thinking", "content": p.content}
1853
+ signature = _serialize_signature(p.thought_signature)
1854
+ if signature is not None:
1855
+ thinking_block["thought_signature"] = signature
1856
+ content_blocks.append(thinking_block)
1556
1857
  serialized.append({"role": msg.role, "content": content_blocks})
1557
1858
 
1558
1859
  return {"messages": serialized}
1559
1860
 
1861
+ def print(self, max_text_length: int = 500, indent: int = 2) -> None:
1862
+ """Pretty-print the conversation to stdout.
1863
+
1864
+ Args:
1865
+ max_text_length: Truncate text content longer than this (default 500 chars)
1866
+ indent: JSON indentation for tool calls/results (default 2)
1867
+ """
1868
+ ROLE_COLORS = {
1869
+ "system": "\033[95m", # magenta
1870
+ "user": "\033[94m", # blue
1871
+ "assistant": "\033[92m", # green
1872
+ "tool": "\033[93m", # yellow
1873
+ }
1874
+ RESET = "\033[0m"
1875
+ DIM = "\033[2m"
1876
+ BOLD = "\033[1m"
1877
+
1878
+ def truncate(text: str, max_len: int) -> str:
1879
+ if len(text) <= max_len:
1880
+ return text
1881
+ return (
1882
+ text[:max_len] + f"{DIM}... [{len(text) - max_len} more chars]{RESET}"
1883
+ )
1884
+
1885
+ def format_json(obj: dict | list, ind: int) -> str:
1886
+ return json.dumps(obj, indent=ind, ensure_ascii=False)
1887
+
1888
+ print(f"\n{BOLD}{'=' * 60}{RESET}")
1889
+ print(f"{BOLD}Conversation ({len(self.messages)} messages){RESET}")
1890
+ print(f"{BOLD}{'=' * 60}{RESET}\n")
1891
+
1892
+ for i, msg in enumerate(self.messages):
1893
+ role_color = ROLE_COLORS.get(msg.role, "")
1894
+ print(f"{role_color}{BOLD}[{msg.role.upper()}]{RESET}")
1895
+
1896
+ for part in msg.parts:
1897
+ if isinstance(part, Text):
1898
+ text = truncate(part.text, max_text_length)
1899
+ # Indent multiline text
1900
+ lines = text.split("\n")
1901
+ if len(lines) > 1:
1902
+ print(" " + "\n ".join(lines))
1903
+ else:
1904
+ print(f" {text}")
1905
+
1906
+ elif isinstance(part, Image):
1907
+ w, h = part.size
1908
+ print(f" {DIM}<Image ({w}x{h})>{RESET}")
1909
+
1910
+ elif isinstance(part, File):
1911
+ size = part.size
1912
+ filename = getattr(part, "filename", None)
1913
+ if filename:
1914
+ print(f" {DIM}<File: {filename} ({size} bytes)>{RESET}")
1915
+ else:
1916
+ print(f" {DIM}<File ({size} bytes)>{RESET}")
1917
+
1918
+ elif isinstance(part, ToolCall):
1919
+ print(
1920
+ f" {DIM}Tool Call:{RESET} {BOLD}{part.name}{RESET} (id: {part.id})"
1921
+ )
1922
+ if part.arguments:
1923
+ args_json = format_json(part.arguments, indent)
1924
+ # Indent the JSON
1925
+ indented = "\n".join(
1926
+ " " + line for line in args_json.split("\n")
1927
+ )
1928
+ print(indented)
1929
+
1930
+ elif isinstance(part, ToolResult):
1931
+ print(f" {DIM}Tool Result:{RESET} (call_id: {part.tool_call_id})")
1932
+ if isinstance(part.result, str):
1933
+ result_text = truncate(part.result, max_text_length)
1934
+ lines = result_text.split("\n")
1935
+ for line in lines:
1936
+ print(f" {line}")
1937
+ elif isinstance(part.result, dict):
1938
+ result_json = format_json(part.result, indent)
1939
+ indented = "\n".join(
1940
+ " " + line for line in result_json.split("\n")
1941
+ )
1942
+ print(indented)
1943
+ elif isinstance(part.result, list):
1944
+ print(f" {DIM}<{len(part.result)} content blocks>{RESET}")
1945
+ for block in part.result:
1946
+ if isinstance(block, Text):
1947
+ block_text = truncate(block.text, max_text_length // 2)
1948
+ print(f" [text] {block_text}")
1949
+ elif isinstance(block, Image):
1950
+ bw, bh = block.size
1951
+ print(f" {DIM}<Image ({bw}x{bh})>{RESET}")
1952
+
1953
+ elif isinstance(part, Thinking):
1954
+ print(f" {DIM}Thinking:{RESET}")
1955
+ thought = truncate(part.content, max_text_length)
1956
+ lines = thought.split("\n")
1957
+ for line in lines:
1958
+ print(f" {DIM}{line}{RESET}")
1959
+
1960
+ # Separator between messages
1961
+ if i < len(self.messages) - 1:
1962
+ print(f"\n{'-' * 40}\n")
1963
+
1964
+ print(f"\n{BOLD}{'=' * 60}{RESET}\n")
1965
+
1560
1966
  @classmethod
1561
1967
  def from_log(cls, payload: dict) -> "Conversation":
1562
1968
  """Re-hydrate a Conversation previously produced by `to_log()`."""
1969
+ import base64
1970
+
1563
1971
  msgs: list[Message] = []
1564
1972
 
1565
1973
  for m in payload.get("messages", []):
@@ -1568,23 +1976,64 @@ class Conversation:
1568
1976
 
1569
1977
  for p in m["content"]:
1570
1978
  if p["type"] == "text":
1571
- parts.append(Text(p["text"]))
1979
+ parts.append(
1980
+ Text(
1981
+ p["text"],
1982
+ thought_signature=_deserialize_signature(
1983
+ p.get("thought_signature")
1984
+ ),
1985
+ )
1986
+ )
1572
1987
  elif p["type"] == "image":
1573
- # We only stored a placeholder tag; rehydrate as inert text to avoid byte access.
1574
- parts.append(Text(p["tag"]))
1988
+ if "data" in p:
1989
+ # Full image data was preserved
1990
+ parts.append(
1991
+ Image(
1992
+ data=base64.b64decode(p["data"]),
1993
+ media_type=p.get("media_type"),
1994
+ detail=p.get("detail", "auto"),
1995
+ )
1996
+ )
1997
+ else:
1998
+ # Placeholder tag only
1999
+ parts.append(Text(p["tag"]))
1575
2000
  elif p["type"] == "file":
1576
- # We only stored a placeholder tag; rehydrate as inert text to avoid byte access.
1577
- parts.append(Text(p["tag"]))
2001
+ if "data" in p:
2002
+ # Full file data was preserved
2003
+ parts.append(
2004
+ File(
2005
+ data=base64.b64decode(p["data"]),
2006
+ media_type=p.get("media_type"),
2007
+ filename=p.get("filename"),
2008
+ )
2009
+ )
2010
+ else:
2011
+ # Placeholder tag only
2012
+ parts.append(Text(p["tag"]))
1578
2013
  elif p["type"] == "tool_call":
1579
2014
  parts.append(
1580
- ToolCall(id=p["id"], name=p["name"], arguments=p["arguments"])
2015
+ ToolCall(
2016
+ id=p["id"],
2017
+ name=p["name"],
2018
+ arguments=p["arguments"],
2019
+ thought_signature=_deserialize_signature(
2020
+ p.get("thought_signature")
2021
+ ),
2022
+ )
1581
2023
  )
1582
2024
  elif p["type"] == "tool_result":
1583
2025
  parts.append(
1584
2026
  ToolResult(tool_call_id=p["tool_call_id"], result=p["result"])
1585
2027
  )
1586
2028
  elif p["type"] == "thinking":
1587
- parts.append(Thinking(content=p["content"]))
2029
+ parts.append(
2030
+ Thinking(
2031
+ content=p["content"],
2032
+ thought_signature=_deserialize_signature(
2033
+ p.get("thought_signature")
2034
+ ),
2035
+ )
2036
+ )
1588
2037
  else:
1589
2038
  raise ValueError(f"Unknown part type {p['type']!r}")
1590
2039
 
@@ -1596,7 +2045,7 @@ class Conversation:
1596
2045
  Prompt: TypeAlias = str | list[dict] | Message | Conversation
1597
2046
 
1598
2047
 
1599
- def prompts_to_conversations(prompts: Sequence[Prompt]) -> Sequence[Prompt]:
2048
+ def prompts_to_conversations(prompts: Sequence[Prompt]) -> Sequence[Conversation]:
1600
2049
  converted = []
1601
2050
  for prompt in prompts:
1602
2051
  if isinstance(prompt, Conversation):