lm-deluge 0.0.67__py3-none-any.whl → 0.0.88__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 (92) hide show
  1. lm_deluge/__init__.py +25 -2
  2. lm_deluge/api_requests/anthropic.py +92 -17
  3. lm_deluge/api_requests/base.py +47 -11
  4. lm_deluge/api_requests/bedrock.py +7 -4
  5. lm_deluge/api_requests/chat_reasoning.py +4 -0
  6. lm_deluge/api_requests/gemini.py +138 -18
  7. lm_deluge/api_requests/openai.py +114 -21
  8. lm_deluge/client.py +282 -49
  9. lm_deluge/config.py +15 -3
  10. lm_deluge/mock_openai.py +643 -0
  11. lm_deluge/models/__init__.py +12 -1
  12. lm_deluge/models/anthropic.py +17 -2
  13. lm_deluge/models/arcee.py +16 -0
  14. lm_deluge/models/deepseek.py +36 -4
  15. lm_deluge/models/google.py +29 -0
  16. lm_deluge/models/grok.py +24 -0
  17. lm_deluge/models/kimi.py +36 -0
  18. lm_deluge/models/minimax.py +10 -0
  19. lm_deluge/models/openai.py +100 -0
  20. lm_deluge/models/openrouter.py +86 -8
  21. lm_deluge/models/together.py +11 -0
  22. lm_deluge/models/zai.py +1 -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 +696 -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 +224 -40
  37. lm_deluge/request_context.py +7 -2
  38. lm_deluge/tool/__init__.py +1118 -0
  39. lm_deluge/tool/builtin/anthropic/__init__.py +300 -0
  40. lm_deluge/tool/builtin/gemini.py +59 -0
  41. lm_deluge/tool/builtin/openai.py +74 -0
  42. lm_deluge/tool/cua/__init__.py +173 -0
  43. lm_deluge/tool/cua/actions.py +148 -0
  44. lm_deluge/tool/cua/base.py +27 -0
  45. lm_deluge/tool/cua/batch.py +215 -0
  46. lm_deluge/tool/cua/converters.py +466 -0
  47. lm_deluge/tool/cua/kernel.py +702 -0
  48. lm_deluge/tool/cua/trycua.py +989 -0
  49. lm_deluge/tool/prefab/__init__.py +45 -0
  50. lm_deluge/tool/prefab/batch_tool.py +156 -0
  51. lm_deluge/tool/prefab/docs.py +1119 -0
  52. lm_deluge/tool/prefab/email.py +294 -0
  53. lm_deluge/tool/prefab/filesystem.py +1711 -0
  54. lm_deluge/tool/prefab/full_text_search/__init__.py +285 -0
  55. lm_deluge/tool/prefab/full_text_search/tantivy_index.py +396 -0
  56. lm_deluge/tool/prefab/memory.py +458 -0
  57. lm_deluge/tool/prefab/otc/__init__.py +165 -0
  58. lm_deluge/tool/prefab/otc/executor.py +281 -0
  59. lm_deluge/tool/prefab/otc/parse.py +188 -0
  60. lm_deluge/tool/prefab/random.py +212 -0
  61. lm_deluge/tool/prefab/rlm/__init__.py +296 -0
  62. lm_deluge/tool/prefab/rlm/executor.py +349 -0
  63. lm_deluge/tool/prefab/rlm/parse.py +144 -0
  64. lm_deluge/tool/prefab/sandbox.py +1621 -0
  65. lm_deluge/tool/prefab/sheets.py +385 -0
  66. lm_deluge/tool/prefab/subagents.py +233 -0
  67. lm_deluge/tool/prefab/todos.py +342 -0
  68. lm_deluge/tool/prefab/tool_search.py +169 -0
  69. lm_deluge/tool/prefab/web_search.py +199 -0
  70. lm_deluge/tracker.py +16 -13
  71. lm_deluge/util/schema.py +412 -0
  72. lm_deluge/warnings.py +8 -0
  73. {lm_deluge-0.0.67.dist-info → lm_deluge-0.0.88.dist-info}/METADATA +22 -9
  74. lm_deluge-0.0.88.dist-info/RECORD +117 -0
  75. lm_deluge/built_in_tools/anthropic/__init__.py +0 -128
  76. lm_deluge/built_in_tools/openai.py +0 -28
  77. lm_deluge/presets/cerebras.py +0 -17
  78. lm_deluge/presets/meta.py +0 -13
  79. lm_deluge/tool.py +0 -849
  80. lm_deluge-0.0.67.dist-info/RECORD +0 -72
  81. lm_deluge/{llm_tools → pipelines}/__init__.py +1 -1
  82. /lm_deluge/{llm_tools → pipelines}/classify.py +0 -0
  83. /lm_deluge/{llm_tools → pipelines}/extract.py +0 -0
  84. /lm_deluge/{llm_tools → pipelines}/locate.py +0 -0
  85. /lm_deluge/{llm_tools → pipelines}/ocr.py +0 -0
  86. /lm_deluge/{built_in_tools → tool/builtin}/anthropic/bash.py +0 -0
  87. /lm_deluge/{built_in_tools → tool/builtin}/anthropic/computer_use.py +0 -0
  88. /lm_deluge/{built_in_tools → tool/builtin}/anthropic/editor.py +0 -0
  89. /lm_deluge/{built_in_tools → tool/builtin}/base.py +0 -0
  90. {lm_deluge-0.0.67.dist-info → lm_deluge-0.0.88.dist-info}/WHEEL +0 -0
  91. {lm_deluge-0.0.67.dist-info → lm_deluge-0.0.88.dist-info}/licenses/LICENSE +0 -0
  92. {lm_deluge-0.0.67.dist-info → lm_deluge-0.0.88.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,165 @@
1
+ """
2
+ Utility functions for GEPA.
3
+
4
+ Includes conversation formatting and text extraction helpers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import Any
11
+
12
+ from lm_deluge.prompt import Conversation
13
+
14
+
15
+ def format_conversation_compact(conversation: Conversation) -> str:
16
+ """
17
+ Format a Conversation for showing to the proposer LLM.
18
+
19
+ Goals:
20
+ - Show full user and assistant message content
21
+ - Show tool calls with their arguments
22
+ - Abbreviate tool results (just show placeholder, not full content)
23
+ - No decorative separators, keep it compact
24
+
25
+ Args:
26
+ conversation: The conversation to format
27
+
28
+ Returns:
29
+ A string representation suitable for including in a prompt
30
+ """
31
+ lines: list[str] = []
32
+
33
+ # Check for system message (first message with role="system")
34
+ for msg in conversation.messages:
35
+ if msg.role == "system":
36
+ lines.append(f"[system]\n{msg.completion}")
37
+ lines.append("")
38
+ break
39
+
40
+ for msg in conversation.messages:
41
+ role = msg.role
42
+
43
+ if role == "system":
44
+ # Already handled above
45
+ continue
46
+
47
+ if role == "user":
48
+ text_content = msg.completion or ""
49
+ lines.append(f"[user]\n{text_content}")
50
+
51
+ elif role == "assistant":
52
+ # Handle text content
53
+ text_content = msg.completion or ""
54
+ if text_content:
55
+ lines.append(f"[assistant]\n{text_content}")
56
+
57
+ # Handle tool calls
58
+ if msg.tool_calls:
59
+ for tc in msg.tool_calls:
60
+ tool_name = tc.name
61
+ # Format arguments compactly
62
+ args_str = _format_tool_args(tc.arguments)
63
+ lines.append(f"[tool_call: {tool_name}]\n{args_str}")
64
+
65
+ elif role == "tool":
66
+ # Just show placeholder for tool results - content can be huge
67
+ # Try to get tool names from tool_results
68
+ if msg.tool_results:
69
+ for tr in msg.tool_results:
70
+ tool_id = getattr(tr, "tool_call_id", "unknown")
71
+ lines.append(f"[tool_result: {tool_id}] (content omitted)")
72
+ else:
73
+ lines.append("[tool_result] (content omitted)")
74
+
75
+ lines.append("")
76
+
77
+ return "\n".join(lines).strip()
78
+
79
+
80
+ def _format_tool_args(arguments: dict[str, Any] | str | None) -> str:
81
+ """Format tool call arguments compactly."""
82
+ if arguments is None:
83
+ return "(no arguments)"
84
+
85
+ if isinstance(arguments, str):
86
+ # Already a string (might be JSON string)
87
+ return arguments[:500] + "..." if len(arguments) > 500 else arguments
88
+
89
+ if isinstance(arguments, dict):
90
+ # Format as key=value pairs
91
+ parts = []
92
+ for key, value in arguments.items():
93
+ value_str = str(value)
94
+ # Truncate long values
95
+ if len(value_str) > 200:
96
+ value_str = value_str[:200] + "..."
97
+ parts.append(f" {key}: {value_str}")
98
+ return "\n".join(parts) if parts else "(no arguments)"
99
+
100
+ return str(arguments)
101
+
102
+
103
+ def extract_text_from_response(response: str) -> str:
104
+ """
105
+ Extract text from between ``` blocks in LLM response.
106
+
107
+ Handles various formats:
108
+ - ```text``` or ```language\ntext```
109
+ - Incomplete blocks
110
+ - No blocks (returns trimmed response)
111
+ """
112
+ # Find content between first and last ```
113
+ start = response.find("```")
114
+ if start == -1:
115
+ return response.strip()
116
+
117
+ start += 3
118
+ end = response.rfind("```")
119
+
120
+ if end <= start:
121
+ # Handle incomplete blocks
122
+ stripped = response.strip()
123
+ if stripped.startswith("```"):
124
+ match = re.match(r"^```\S*\n?", response)
125
+ if match:
126
+ return response[match.end() :].strip()
127
+ elif stripped.endswith("```"):
128
+ return stripped[:-3].strip()
129
+ return stripped
130
+
131
+ # Skip language specifier (e.g., ```python\n)
132
+ content = response[start:end]
133
+ match = re.match(r"^\S*\n", content)
134
+ if match:
135
+ content = content[match.end() :]
136
+
137
+ return content.strip()
138
+
139
+
140
+ def format_components_for_prompt(
141
+ component_values: dict[str, str],
142
+ component_descriptions: dict[str, str],
143
+ ) -> str:
144
+ """
145
+ Format components for showing to the proposer.
146
+
147
+ Args:
148
+ component_values: Current text value for each component
149
+ component_descriptions: Description of what each component does
150
+
151
+ Returns:
152
+ Formatted string listing all components
153
+ """
154
+ lines = []
155
+ for name, value in component_values.items():
156
+ description = component_descriptions.get(name, "")
157
+ lines.append(f"### {name}")
158
+ if description:
159
+ lines.append(f"*{description}*")
160
+ lines.append("```")
161
+ lines.append(value)
162
+ lines.append("```")
163
+ lines.append("")
164
+
165
+ return "\n".join(lines)
@@ -1,4 +1,4 @@
1
- from ..client import LLMClient, APIResponse
1
+ from ..client import _LLMClient, APIResponse
2
2
  from ..util.logprobs import extract_prob
3
3
 
4
4
  # def extract_prob_yes(logprobs: list[dict]):
@@ -24,7 +24,7 @@ from ..util.logprobs import extract_prob
24
24
  def score_llm(
25
25
  scoring_prompt_template: str,
26
26
  inputs: list[tuple | list | dict], # to format the template
27
- scoring_model: LLMClient,
27
+ scoring_model: _LLMClient,
28
28
  return_probabilities: bool,
29
29
  yes_token: str = "yes",
30
30
  ) -> list[bool | None] | list[float | None]:
@@ -1,5 +1,5 @@
1
1
  import asyncio
2
- from ..client import LLMClient
2
+ from ..client import _LLMClient
3
3
 
4
4
  translation_prompt = (
5
5
  "Translate the following text (enclosed in ```) into English. "
@@ -20,7 +20,9 @@ def is_english(text: str, low_memory: bool = True):
20
20
  return True
21
21
 
22
22
 
23
- async def translate_async(texts: list[str], client: LLMClient, low_memory: bool = True):
23
+ async def translate_async(
24
+ texts: list[str], client: _LLMClient, low_memory: bool = True
25
+ ):
24
26
  to_translate_idxs = [
25
27
  i for i, text in enumerate(texts) if not is_english(text, low_memory=low_memory)
26
28
  ]
@@ -40,5 +42,5 @@ async def translate_async(texts: list[str], client: LLMClient, low_memory: bool
40
42
  return texts
41
43
 
42
44
 
43
- def translate(texts: list[str], client: LLMClient, low_memory: bool = True):
45
+ def translate(texts: list[str], client: _LLMClient, low_memory: bool = True):
44
46
  return asyncio.run(translate_async(texts, client, low_memory))
lm_deluge/prompt.py CHANGED
@@ -61,6 +61,8 @@ class ToolCall:
61
61
  built_in: bool = False
62
62
  built_in_type: str | None = None
63
63
  extra_body: dict | None = None
64
+ # for gemini 3 - thought signatures to maintain reasoning context
65
+ thought_signature: str | None = None
64
66
 
65
67
  @property
66
68
  def fingerprint(self) -> str:
@@ -93,7 +95,10 @@ class ToolCall:
93
95
  }
94
96
 
95
97
  def gemini(self) -> dict:
96
- return {"functionCall": {"name": self.name, "args": self.arguments}}
98
+ result = {"functionCall": {"name": self.name, "args": self.arguments}}
99
+ if self.thought_signature is not None:
100
+ result["thoughtSignature"] = self.thought_signature # type: ignore
101
+ return result
97
102
 
98
103
  def mistral(self) -> dict:
99
104
  return {
@@ -198,6 +203,8 @@ class ToolResult:
198
203
  "call_id": self.tool_call_id,
199
204
  }
200
205
  if self.built_in_type == "computer_call":
206
+ # OpenAI expects "computer_call_output" for the result type
207
+ result["type"] = "computer_call_output"
201
208
  result["output"] = output_data.get("output", {})
202
209
  if "acknowledged_safety_checks" in output_data:
203
210
  result["acknowledged_safety_checks"] = output_data[
@@ -230,15 +237,41 @@ class ToolResult:
230
237
  raise ValueError("unsupported self.result type")
231
238
 
232
239
  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
- }
240
+ # Build the function response
241
+ func_response: dict = {
242
+ "name": self.tool_call_id, # Gemini uses name field for ID
240
243
  }
241
244
 
245
+ # Handle different result types
246
+ if isinstance(self.result, str):
247
+ func_response["response"] = {"result": self.result}
248
+ elif isinstance(self.result, dict):
249
+ # Check for Gemini computer use format with inline screenshot
250
+ if self.built_in_type == "gemini_computer_use":
251
+ # Gemini CU expects response dict with optional inline_data parts
252
+ func_response["response"] = self.result.get("response", {})
253
+ # Include inline data (screenshot) if present
254
+ if "inline_data" in self.result:
255
+ func_response["parts"] = [
256
+ {
257
+ "inlineData": {
258
+ "mimeType": self.result["inline_data"].get(
259
+ "mime_type", "image/png"
260
+ ),
261
+ "data": self.result["inline_data"]["data"],
262
+ }
263
+ }
264
+ ]
265
+ else:
266
+ func_response["response"] = self.result
267
+ elif isinstance(self.result, list):
268
+ # Handle content blocks (images, etc.) - not yet implemented
269
+ raise ValueError("can't handle content blocks for gemini yet")
270
+ else:
271
+ func_response["response"] = {"result": str(self.result)}
272
+
273
+ return {"functionResponse": func_response}
274
+
242
275
  def mistral(self) -> dict:
243
276
  return {
244
277
  "type": "tool_result",
@@ -253,6 +286,8 @@ class Thinking:
253
286
  type: str = field(init=False, default="thinking")
254
287
  # for openai - to keep conversation chain
255
288
  raw_payload: dict | None = None
289
+ # for gemini 3 - thought signatures to maintain reasoning context
290
+ thought_signature: str | None = None
256
291
 
257
292
  @property
258
293
  def fingerprint(self) -> str:
@@ -270,7 +305,10 @@ class Thinking:
270
305
  return {"type": "thinking", "thinking": self.content}
271
306
 
272
307
  def gemini(self) -> dict:
273
- return {"text": f"[Thinking: {self.content}]"}
308
+ result = {"text": f"[Thinking: {self.content}]"}
309
+ if self.thought_signature is not None:
310
+ result["thoughtSignature"] = self.thought_signature
311
+ return result
274
312
 
275
313
  def mistral(self) -> dict:
276
314
  return {"type": "text", "text": f"[Thinking: {self.content}]"}
@@ -374,14 +412,15 @@ class Message:
374
412
  size = p.size
375
413
  content_blocks.append({"type": "file", "tag": f"<File ({size} bytes)>"})
376
414
  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
- )
415
+ tool_call_block = {
416
+ "type": "tool_call",
417
+ "id": p.id,
418
+ "name": p.name,
419
+ "arguments": _json_safe(p.arguments),
420
+ }
421
+ if p.thought_signature is not None:
422
+ tool_call_block["thought_signature"] = p.thought_signature
423
+ content_blocks.append(tool_call_block)
385
424
  elif isinstance(p, ToolResult):
386
425
  content_blocks.append(
387
426
  {
@@ -391,7 +430,10 @@ class Message:
391
430
  }
392
431
  )
393
432
  elif isinstance(p, Thinking):
394
- content_blocks.append({"type": "thinking", "content": p.content})
433
+ thinking_block = {"type": "thinking", "content": p.content}
434
+ if p.thought_signature is not None:
435
+ thinking_block["thought_signature"] = p.thought_signature
436
+ content_blocks.append(thinking_block)
395
437
 
396
438
  return {"role": self.role, "content": content_blocks}
397
439
 
@@ -415,14 +457,24 @@ class Message:
415
457
  parts.append(Text(p["tag"]))
416
458
  elif p["type"] == "tool_call":
417
459
  parts.append(
418
- ToolCall(id=p["id"], name=p["name"], arguments=p["arguments"])
460
+ ToolCall(
461
+ id=p["id"],
462
+ name=p["name"],
463
+ arguments=p["arguments"],
464
+ thought_signature=p.get("thought_signature"),
465
+ )
419
466
  )
420
467
  elif p["type"] == "tool_result":
421
468
  parts.append(
422
469
  ToolResult(tool_call_id=p["tool_call_id"], result=p["result"])
423
470
  )
424
471
  elif p["type"] == "thinking":
425
- parts.append(Thinking(content=p["content"]))
472
+ parts.append(
473
+ Thinking(
474
+ content=p["content"],
475
+ thought_signature=p.get("thought_signature"),
476
+ )
477
+ )
426
478
  else:
427
479
  raise ValueError(f"Unknown part type {p['type']!r}")
428
480
 
@@ -848,14 +900,16 @@ class Conversation:
848
900
  if content is None:
849
901
  return parts
850
902
  if isinstance(content, str):
851
- parts.append(Text(content))
903
+ if content.strip():
904
+ parts.append(Text(content))
852
905
  return parts
853
906
 
854
907
  for block in content:
855
908
  block_type = block.get("type")
856
909
  if block_type in text_types:
857
910
  text_value = block.get("text") or block.get(block_type) or ""
858
- parts.append(Text(text_value))
911
+ if text_value.strip():
912
+ parts.append(Text(text_value))
859
913
  elif block_type in image_types:
860
914
  parts.append(_to_image_from_url(block))
861
915
  elif block_type in file_types:
@@ -1001,7 +1055,8 @@ class Conversation:
1001
1055
  )
1002
1056
  )
1003
1057
 
1004
- conversation_messages.append(Message(mapped_role, parts))
1058
+ if parts:
1059
+ conversation_messages.append(Message(mapped_role, parts))
1005
1060
 
1006
1061
  return cls(conversation_messages)
1007
1062
 
@@ -1192,14 +1247,24 @@ class Conversation:
1192
1247
 
1193
1248
  @classmethod
1194
1249
  def from_unknown(
1195
- cls, messages: list[dict], *, system: str | list[dict] | None = None
1250
+ cls, messages: list[dict] | dict, *, system: str | list[dict] | None = None
1196
1251
  ) -> tuple["Conversation", str]:
1197
1252
  """Attempt to convert provider-formatted messages without knowing the provider.
1198
1253
 
1199
1254
  Returns the parsed conversation together with the provider label that succeeded
1200
- ("openai" or "anthropic").
1255
+ ("openai", "anthropic", or "log").
1201
1256
  """
1202
1257
 
1258
+ # Check if input is in log format (output from to_log())
1259
+ if isinstance(messages, dict) and "messages" in messages:
1260
+ return cls.from_log(messages), "log"
1261
+
1262
+ # Ensure messages is a list for provider detection
1263
+ if not isinstance(messages, list):
1264
+ raise ValueError(
1265
+ "messages must be a list of dicts or a dict with 'messages' key"
1266
+ )
1267
+
1203
1268
  def _detect_provider() -> str:
1204
1269
  has_openai_markers = False
1205
1270
  has_anthropic_markers = False
@@ -1330,14 +1395,14 @@ class Conversation:
1330
1395
  # For assistant messages, extract computer calls as separate items
1331
1396
  text_parts = []
1332
1397
  for p in m.parts:
1333
- if isinstance(p, ToolCall) and p.name.startswith("_computer_"):
1398
+ if isinstance(p, ToolCall) and p.built_in_type == "computer_call":
1334
1399
  # Computer calls become separate items in the input array
1335
- action_type = p.name.replace("_computer_", "")
1400
+ # p.arguments already contains the full action dict with "type"
1336
1401
  input_items.append(
1337
1402
  {
1338
1403
  "type": "computer_call",
1339
1404
  "call_id": p.id,
1340
- "action": {"type": action_type, **p.arguments},
1405
+ "action": p.arguments,
1341
1406
  }
1342
1407
  )
1343
1408
  elif isinstance(p, Text):
@@ -1533,14 +1598,15 @@ class Conversation:
1533
1598
  {"type": "file", "tag": f"<File ({size} bytes)>"}
1534
1599
  )
1535
1600
  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
- )
1601
+ tool_call_block = {
1602
+ "type": "tool_call",
1603
+ "id": p.id,
1604
+ "name": p.name,
1605
+ "arguments": p.arguments,
1606
+ }
1607
+ if p.thought_signature is not None:
1608
+ tool_call_block["thought_signature"] = p.thought_signature
1609
+ content_blocks.append(tool_call_block)
1544
1610
  elif isinstance(p, ToolResult):
1545
1611
  content_blocks.append(
1546
1612
  {
@@ -1552,11 +1618,119 @@ class Conversation:
1552
1618
  }
1553
1619
  )
1554
1620
  elif isinstance(p, Thinking):
1555
- content_blocks.append({"type": "thinking", "content": p.content})
1621
+ thinking_block = {"type": "thinking", "content": p.content}
1622
+ if p.thought_signature is not None:
1623
+ thinking_block["thought_signature"] = p.thought_signature
1624
+ content_blocks.append(thinking_block)
1556
1625
  serialized.append({"role": msg.role, "content": content_blocks})
1557
1626
 
1558
1627
  return {"messages": serialized}
1559
1628
 
1629
+ def print(self, max_text_length: int = 500, indent: int = 2) -> None:
1630
+ """Pretty-print the conversation to stdout.
1631
+
1632
+ Args:
1633
+ max_text_length: Truncate text content longer than this (default 500 chars)
1634
+ indent: JSON indentation for tool calls/results (default 2)
1635
+ """
1636
+ ROLE_COLORS = {
1637
+ "system": "\033[95m", # magenta
1638
+ "user": "\033[94m", # blue
1639
+ "assistant": "\033[92m", # green
1640
+ "tool": "\033[93m", # yellow
1641
+ }
1642
+ RESET = "\033[0m"
1643
+ DIM = "\033[2m"
1644
+ BOLD = "\033[1m"
1645
+
1646
+ def truncate(text: str, max_len: int) -> str:
1647
+ if len(text) <= max_len:
1648
+ return text
1649
+ return (
1650
+ text[:max_len] + f"{DIM}... [{len(text) - max_len} more chars]{RESET}"
1651
+ )
1652
+
1653
+ def format_json(obj: dict | list, ind: int) -> str:
1654
+ return json.dumps(obj, indent=ind, ensure_ascii=False)
1655
+
1656
+ print(f"\n{BOLD}{'=' * 60}{RESET}")
1657
+ print(f"{BOLD}Conversation ({len(self.messages)} messages){RESET}")
1658
+ print(f"{BOLD}{'=' * 60}{RESET}\n")
1659
+
1660
+ for i, msg in enumerate(self.messages):
1661
+ role_color = ROLE_COLORS.get(msg.role, "")
1662
+ print(f"{role_color}{BOLD}[{msg.role.upper()}]{RESET}")
1663
+
1664
+ for part in msg.parts:
1665
+ if isinstance(part, Text):
1666
+ text = truncate(part.text, max_text_length)
1667
+ # Indent multiline text
1668
+ lines = text.split("\n")
1669
+ if len(lines) > 1:
1670
+ print(" " + "\n ".join(lines))
1671
+ else:
1672
+ print(f" {text}")
1673
+
1674
+ elif isinstance(part, Image):
1675
+ w, h = part.size
1676
+ print(f" {DIM}<Image ({w}x{h})>{RESET}")
1677
+
1678
+ elif isinstance(part, File):
1679
+ size = part.size
1680
+ filename = getattr(part, "filename", None)
1681
+ if filename:
1682
+ print(f" {DIM}<File: {filename} ({size} bytes)>{RESET}")
1683
+ else:
1684
+ print(f" {DIM}<File ({size} bytes)>{RESET}")
1685
+
1686
+ elif isinstance(part, ToolCall):
1687
+ print(
1688
+ f" {DIM}Tool Call:{RESET} {BOLD}{part.name}{RESET} (id: {part.id})"
1689
+ )
1690
+ if part.arguments:
1691
+ args_json = format_json(part.arguments, indent)
1692
+ # Indent the JSON
1693
+ indented = "\n".join(
1694
+ " " + line for line in args_json.split("\n")
1695
+ )
1696
+ print(indented)
1697
+
1698
+ elif isinstance(part, ToolResult):
1699
+ print(f" {DIM}Tool Result:{RESET} (call_id: {part.tool_call_id})")
1700
+ if isinstance(part.result, str):
1701
+ result_text = truncate(part.result, max_text_length)
1702
+ lines = result_text.split("\n")
1703
+ for line in lines:
1704
+ print(f" {line}")
1705
+ elif isinstance(part.result, dict):
1706
+ result_json = format_json(part.result, indent)
1707
+ indented = "\n".join(
1708
+ " " + line for line in result_json.split("\n")
1709
+ )
1710
+ print(indented)
1711
+ elif isinstance(part.result, list):
1712
+ print(f" {DIM}<{len(part.result)} content blocks>{RESET}")
1713
+ for block in part.result:
1714
+ if isinstance(block, Text):
1715
+ block_text = truncate(block.text, max_text_length // 2)
1716
+ print(f" [text] {block_text}")
1717
+ elif isinstance(block, Image):
1718
+ bw, bh = block.size
1719
+ print(f" {DIM}<Image ({bw}x{bh})>{RESET}")
1720
+
1721
+ elif isinstance(part, Thinking):
1722
+ print(f" {DIM}Thinking:{RESET}")
1723
+ thought = truncate(part.content, max_text_length)
1724
+ lines = thought.split("\n")
1725
+ for line in lines:
1726
+ print(f" {DIM}{line}{RESET}")
1727
+
1728
+ # Separator between messages
1729
+ if i < len(self.messages) - 1:
1730
+ print(f"\n{'-' * 40}\n")
1731
+
1732
+ print(f"\n{BOLD}{'=' * 60}{RESET}\n")
1733
+
1560
1734
  @classmethod
1561
1735
  def from_log(cls, payload: dict) -> "Conversation":
1562
1736
  """Re-hydrate a Conversation previously produced by `to_log()`."""
@@ -1577,14 +1751,24 @@ class Conversation:
1577
1751
  parts.append(Text(p["tag"]))
1578
1752
  elif p["type"] == "tool_call":
1579
1753
  parts.append(
1580
- ToolCall(id=p["id"], name=p["name"], arguments=p["arguments"])
1754
+ ToolCall(
1755
+ id=p["id"],
1756
+ name=p["name"],
1757
+ arguments=p["arguments"],
1758
+ thought_signature=p.get("thought_signature"),
1759
+ )
1581
1760
  )
1582
1761
  elif p["type"] == "tool_result":
1583
1762
  parts.append(
1584
1763
  ToolResult(tool_call_id=p["tool_call_id"], result=p["result"])
1585
1764
  )
1586
1765
  elif p["type"] == "thinking":
1587
- parts.append(Thinking(content=p["content"]))
1766
+ parts.append(
1767
+ Thinking(
1768
+ content=p["content"],
1769
+ thought_signature=p.get("thought_signature"),
1770
+ )
1771
+ )
1588
1772
  else:
1589
1773
  raise ValueError(f"Unknown part type {p['type']!r}")
1590
1774
 
@@ -1596,7 +1780,7 @@ class Conversation:
1596
1780
  Prompt: TypeAlias = str | list[dict] | Message | Conversation
1597
1781
 
1598
1782
 
1599
- def prompts_to_conversations(prompts: Sequence[Prompt]) -> Sequence[Prompt]:
1783
+ def prompts_to_conversations(prompts: Sequence[Prompt]) -> Sequence[Conversation]:
1600
1784
  converted = []
1601
1785
  for prompt in prompts:
1602
1786
  if isinstance(prompt, Conversation):
@@ -1,11 +1,14 @@
1
1
  from dataclasses import dataclass, field
2
2
  from functools import cached_property
3
- from typing import Any, Callable
3
+ from typing import Any, Callable, Sequence, TYPE_CHECKING
4
4
 
5
5
  from .config import SamplingParams
6
6
  from .prompt import CachePattern, Conversation
7
7
  from .tracker import StatusTracker
8
8
 
9
+ if TYPE_CHECKING:
10
+ from pydantic import BaseModel
11
+
9
12
 
10
13
  @dataclass
11
14
  class RequestContext:
@@ -31,7 +34,8 @@ class RequestContext:
31
34
  callback: Callable | None = None
32
35
 
33
36
  # Optional features
34
- tools: list | None = None
37
+ tools: Sequence[Any] | None = None
38
+ output_schema: "type[BaseModel] | dict | None" = None
35
39
  cache: CachePattern | None = None
36
40
  use_responses_api: bool = False
37
41
  background: bool = False
@@ -66,6 +70,7 @@ class RequestContext:
66
70
  "results_arr": self.results_arr,
67
71
  "callback": self.callback,
68
72
  "tools": self.tools,
73
+ "output_schema": self.output_schema,
69
74
  "cache": self.cache,
70
75
  "use_responses_api": self.use_responses_api,
71
76
  "background": self.background,