devcopilot 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. api/__init__.py +17 -0
  2. api/admin_config.py +1303 -0
  3. api/admin_routes.py +287 -0
  4. api/admin_static/admin.css +459 -0
  5. api/admin_static/admin.js +497 -0
  6. api/admin_static/index.html +77 -0
  7. api/admin_urls.py +34 -0
  8. api/app.py +194 -0
  9. api/command_utils.py +164 -0
  10. api/dependencies.py +144 -0
  11. api/detection.py +152 -0
  12. api/gateway_model_ids.py +54 -0
  13. api/model_catalog.py +133 -0
  14. api/model_router.py +125 -0
  15. api/models/__init__.py +45 -0
  16. api/models/anthropic.py +234 -0
  17. api/models/openai_responses.py +28 -0
  18. api/models/responses.py +60 -0
  19. api/optimization_handlers.py +154 -0
  20. api/request_pipeline.py +424 -0
  21. api/routes.py +156 -0
  22. api/runtime.py +334 -0
  23. api/validation_log.py +48 -0
  24. api/web_server_tools.py +22 -0
  25. api/web_tools/__init__.py +17 -0
  26. api/web_tools/constants.py +15 -0
  27. api/web_tools/egress.py +99 -0
  28. api/web_tools/outbound.py +278 -0
  29. api/web_tools/parsers.py +104 -0
  30. api/web_tools/request.py +87 -0
  31. api/web_tools/streaming.py +206 -0
  32. cli/__init__.py +5 -0
  33. cli/claude_env.py +12 -0
  34. cli/entrypoints.py +166 -0
  35. cli/env.example +209 -0
  36. cli/launchers/__init__.py +1 -0
  37. cli/launchers/claude.py +84 -0
  38. cli/launchers/codex.py +204 -0
  39. cli/launchers/codex_model_catalog.py +186 -0
  40. cli/launchers/common.py +93 -0
  41. cli/managed/__init__.py +6 -0
  42. cli/managed/claude.py +215 -0
  43. cli/managed/manager.py +157 -0
  44. cli/managed/session.py +260 -0
  45. cli/process_registry.py +78 -0
  46. config/__init__.py +5 -0
  47. config/constants.py +13 -0
  48. config/logging_config.py +159 -0
  49. config/nim.py +118 -0
  50. config/paths.py +91 -0
  51. config/provider_catalog.py +259 -0
  52. config/provider_ids.py +7 -0
  53. config/settings.py +538 -0
  54. core/__init__.py +1 -0
  55. core/anthropic/__init__.py +46 -0
  56. core/anthropic/content.py +31 -0
  57. core/anthropic/conversion.py +587 -0
  58. core/anthropic/emitted_sse_tracker.py +346 -0
  59. core/anthropic/errors.py +70 -0
  60. core/anthropic/native_messages_request.py +280 -0
  61. core/anthropic/native_sse_block_policy.py +313 -0
  62. core/anthropic/provider_stream_error.py +34 -0
  63. core/anthropic/server_tool_sse.py +14 -0
  64. core/anthropic/sse.py +440 -0
  65. core/anthropic/stream_contracts.py +205 -0
  66. core/anthropic/stream_recovery.py +346 -0
  67. core/anthropic/stream_recovery_session.py +133 -0
  68. core/anthropic/thinking.py +140 -0
  69. core/anthropic/tokens.py +117 -0
  70. core/anthropic/tools.py +212 -0
  71. core/anthropic/utils.py +9 -0
  72. core/openai_responses/__init__.py +5 -0
  73. core/openai_responses/adapter.py +31 -0
  74. core/openai_responses/anthropic_sse.py +59 -0
  75. core/openai_responses/errors.py +22 -0
  76. core/openai_responses/events.py +19 -0
  77. core/openai_responses/ids.py +21 -0
  78. core/openai_responses/input.py +258 -0
  79. core/openai_responses/items.py +37 -0
  80. core/openai_responses/reasoning.py +52 -0
  81. core/openai_responses/stream.py +25 -0
  82. core/openai_responses/stream_state.py +654 -0
  83. core/openai_responses/tools.py +374 -0
  84. core/openai_responses/usage.py +37 -0
  85. core/rate_limit.py +60 -0
  86. core/trace.py +216 -0
  87. devcopilot-0.2.0.dist-info/METADATA +687 -0
  88. devcopilot-0.2.0.dist-info/RECORD +189 -0
  89. devcopilot-0.2.0.dist-info/WHEEL +4 -0
  90. devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
  91. devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
  92. messaging/__init__.py +26 -0
  93. messaging/cli_event_constants.py +67 -0
  94. messaging/command_context.py +66 -0
  95. messaging/command_dispatcher.py +37 -0
  96. messaging/commands.py +275 -0
  97. messaging/event_parser.py +181 -0
  98. messaging/limiter.py +300 -0
  99. messaging/models.py +36 -0
  100. messaging/node_event_pipeline.py +127 -0
  101. messaging/node_runner.py +342 -0
  102. messaging/platforms/__init__.py +15 -0
  103. messaging/platforms/base.py +228 -0
  104. messaging/platforms/discord.py +567 -0
  105. messaging/platforms/factory.py +103 -0
  106. messaging/platforms/outbox.py +144 -0
  107. messaging/platforms/telegram.py +688 -0
  108. messaging/platforms/voice_flow.py +295 -0
  109. messaging/rendering/__init__.py +3 -0
  110. messaging/rendering/discord_markdown.py +318 -0
  111. messaging/rendering/markdown_tables.py +49 -0
  112. messaging/rendering/profiles.py +55 -0
  113. messaging/rendering/telegram_markdown.py +327 -0
  114. messaging/safe_diagnostics.py +17 -0
  115. messaging/session.py +334 -0
  116. messaging/transcript.py +581 -0
  117. messaging/transcription.py +164 -0
  118. messaging/trees/__init__.py +15 -0
  119. messaging/trees/data.py +482 -0
  120. messaging/trees/manager.py +433 -0
  121. messaging/trees/processor.py +179 -0
  122. messaging/trees/repository.py +177 -0
  123. messaging/turn_intake.py +235 -0
  124. messaging/ui_updates.py +101 -0
  125. messaging/voice.py +76 -0
  126. messaging/workflow.py +200 -0
  127. providers/__init__.py +31 -0
  128. providers/base.py +152 -0
  129. providers/cerebras/__init__.py +7 -0
  130. providers/cerebras/client.py +31 -0
  131. providers/cerebras/request.py +55 -0
  132. providers/codestral/__init__.py +7 -0
  133. providers/codestral/client.py +34 -0
  134. providers/deepseek/__init__.py +11 -0
  135. providers/deepseek/client.py +51 -0
  136. providers/deepseek/request.py +475 -0
  137. providers/defaults.py +41 -0
  138. providers/error_mapping.py +309 -0
  139. providers/exceptions.py +113 -0
  140. providers/fireworks/__init__.py +5 -0
  141. providers/fireworks/client.py +45 -0
  142. providers/fireworks/request.py +48 -0
  143. providers/gemini/__init__.py +7 -0
  144. providers/gemini/client.py +49 -0
  145. providers/gemini/request.py +199 -0
  146. providers/groq/__init__.py +7 -0
  147. providers/groq/client.py +31 -0
  148. providers/groq/request.py +83 -0
  149. providers/kimi/__init__.py +10 -0
  150. providers/kimi/client.py +53 -0
  151. providers/kimi/request.py +42 -0
  152. providers/llamacpp/__init__.py +3 -0
  153. providers/llamacpp/client.py +16 -0
  154. providers/lmstudio/__init__.py +5 -0
  155. providers/lmstudio/client.py +16 -0
  156. providers/mistral/__init__.py +7 -0
  157. providers/mistral/client.py +31 -0
  158. providers/mistral/request.py +37 -0
  159. providers/model_listing.py +133 -0
  160. providers/nvidia_nim/__init__.py +7 -0
  161. providers/nvidia_nim/client.py +91 -0
  162. providers/nvidia_nim/request.py +430 -0
  163. providers/nvidia_nim/voice.py +95 -0
  164. providers/ollama/__init__.py +7 -0
  165. providers/ollama/client.py +39 -0
  166. providers/open_router/__init__.py +7 -0
  167. providers/open_router/client.py +124 -0
  168. providers/open_router/request.py +42 -0
  169. providers/opencode/__init__.py +11 -0
  170. providers/opencode/client.py +31 -0
  171. providers/opencode/request.py +35 -0
  172. providers/rate_limit.py +300 -0
  173. providers/registry.py +527 -0
  174. providers/transports/__init__.py +1 -0
  175. providers/transports/anthropic_messages/__init__.py +5 -0
  176. providers/transports/anthropic_messages/http.py +118 -0
  177. providers/transports/anthropic_messages/recovery.py +206 -0
  178. providers/transports/anthropic_messages/stream.py +295 -0
  179. providers/transports/anthropic_messages/transport.py +236 -0
  180. providers/transports/openai_chat/__init__.py +5 -0
  181. providers/transports/openai_chat/recovery.py +217 -0
  182. providers/transports/openai_chat/stream.py +384 -0
  183. providers/transports/openai_chat/tool_calls.py +293 -0
  184. providers/transports/openai_chat/transport.py +156 -0
  185. providers/wafer/__init__.py +10 -0
  186. providers/wafer/client.py +50 -0
  187. providers/zai/__init__.py +10 -0
  188. providers/zai/client.py +46 -0
  189. providers/zai/request.py +42 -0
@@ -0,0 +1,587 @@
1
+ """Message and tool format converters."""
2
+
3
+ import json
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass, field
6
+ from enum import StrEnum
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel
10
+
11
+ from .content import get_block_attr, get_block_type
12
+ from .utils import set_if_not_none
13
+
14
+
15
+ class OpenAIConversionError(Exception):
16
+ """Raised when Anthropic content cannot be converted to OpenAI chat without data loss."""
17
+
18
+
19
+ class ReasoningReplayMode(StrEnum):
20
+ """How assistant reasoning history is replayed to OpenAI-compatible providers."""
21
+
22
+ DISABLED = "disabled"
23
+ THINK_TAGS = "think_tags"
24
+ REASONING_CONTENT = "reasoning_content"
25
+
26
+
27
+ def _openai_reject_native_only_top_level_fields(request_data: Any) -> None:
28
+ """OpenAI chat providers may only convert known top-level request fields.
29
+
30
+ First-class model fields (e.g. ``context_management``) are not forwarded to
31
+ the OpenAI API but are allowed so clients do not hit spurious 400s.
32
+ Unknown extra keys (``__pydantic_extra__``) are still rejected.
33
+ """
34
+ if not isinstance(request_data, BaseModel):
35
+ return
36
+ extra = getattr(request_data, "__pydantic_extra__", None)
37
+ if not extra:
38
+ return
39
+ raise OpenAIConversionError(
40
+ "OpenAI chat conversion does not support these top-level request fields: "
41
+ f"{sorted(str(k) for k in extra)}. Use a native Anthropic transport provider."
42
+ )
43
+
44
+
45
+ def _tool_name(tool: Any) -> str:
46
+ return str(getattr(tool, "name", "") or "")
47
+
48
+
49
+ def _tool_input_schema(tool: Any) -> dict[str, Any]:
50
+ schema = getattr(tool, "input_schema", None)
51
+ if isinstance(schema, dict):
52
+ return schema
53
+ return {"type": "object", "properties": {}}
54
+
55
+
56
+ def _serialize_tool_result_content(tool_content: Any) -> str:
57
+ """Serialize tool_result content for OpenAI ``role: tool`` messages (stable JSON for structured values)."""
58
+ if tool_content is None:
59
+ return ""
60
+ if isinstance(tool_content, str):
61
+ return tool_content
62
+ if isinstance(tool_content, dict):
63
+ return json.dumps(tool_content, ensure_ascii=False)
64
+ if isinstance(tool_content, list):
65
+ parts: list[str] = []
66
+ for item in tool_content:
67
+ if isinstance(item, dict) and item.get("type") == "text":
68
+ parts.append(str(item.get("text", "")))
69
+ elif isinstance(item, dict):
70
+ parts.append(json.dumps(item, ensure_ascii=False))
71
+ else:
72
+ parts.append(str(item))
73
+ return "\n".join(parts)
74
+ return str(tool_content)
75
+
76
+
77
+ def _clean_reasoning_content(value: Any) -> str | None:
78
+ if not isinstance(value, str):
79
+ return None
80
+ return value if value else None
81
+
82
+
83
+ def _think_tag_content(reasoning: str) -> str:
84
+ return f"<think>\n{reasoning}\n</think>"
85
+
86
+
87
+ def _tool_call_from_tool_use(block: Any) -> dict[str, Any]:
88
+ tool_input = get_block_attr(block, "input", {})
89
+ tool_call: dict[str, Any] = {
90
+ "id": get_block_attr(block, "id"),
91
+ "type": "function",
92
+ "function": {
93
+ "name": get_block_attr(block, "name"),
94
+ "arguments": json.dumps(tool_input)
95
+ if isinstance(tool_input, dict)
96
+ else str(tool_input),
97
+ },
98
+ }
99
+ extra_content = get_block_attr(block, "extra_content", None)
100
+ if isinstance(extra_content, dict) and extra_content:
101
+ tool_call["extra_content"] = deepcopy(extra_content)
102
+ return tool_call
103
+
104
+
105
+ @dataclass
106
+ class _PendingAfterTools:
107
+ """Assistant content that appears after ``tool_use`` in an Anthropic message.
108
+
109
+ OpenAI ``chat.completions`` cannot place assistant text after ``tool_calls`` in the
110
+ same message, so it is deferred until the corresponding ``role: tool`` results have
111
+ been replayed in order.
112
+ """
113
+
114
+ # Tool use IDs still missing a ``role: tool`` result before post-tool text may be replayed.
115
+ remaining_tool_ids: set[str] = field(default_factory=set)
116
+ deferred_blocks: list[Any] = field(default_factory=list)
117
+ top_level_reasoning: str | None = None
118
+ reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS
119
+ # True after deferred assistant text has been added to the OpenAI transcript.
120
+ deferred_emitted: bool = False
121
+
122
+ def needs_deferred(self) -> bool:
123
+ return bool(self.deferred_blocks) and not self.deferred_emitted
124
+
125
+
126
+ def _index_first_tool_use(blocks: list[Any]) -> int | None:
127
+ for i, block in enumerate(blocks):
128
+ if get_block_type(block) == "tool_use":
129
+ return i
130
+ return None
131
+
132
+
133
+ def _iter_tool_uses_in_order(blocks: list[Any]) -> list[dict[str, Any]]:
134
+ return [
135
+ _tool_call_from_tool_use(block)
136
+ for block in blocks
137
+ if get_block_type(block) == "tool_use"
138
+ ]
139
+
140
+
141
+ def _deferred_post_tool_blocks(
142
+ content: list[Any], *, first_tool_index: int
143
+ ) -> list[Any]:
144
+ return [
145
+ b
146
+ for i, b in enumerate(content)
147
+ if i > first_tool_index and get_block_type(b) != "tool_use"
148
+ ]
149
+
150
+
151
+ def _assert_no_forbidden_assistant_block(block: Any) -> None:
152
+ block_type = get_block_type(block)
153
+ if block_type == "image":
154
+ raise OpenAIConversionError(
155
+ "Assistant image blocks are not supported for OpenAI chat conversion."
156
+ )
157
+ if block_type in (
158
+ "server_tool_use",
159
+ "web_search_tool_result",
160
+ "web_fetch_tool_result",
161
+ ):
162
+ raise OpenAIConversionError(
163
+ "OpenAI chat conversion does not support Anthropic server tool blocks "
164
+ f"({block_type!r} in an assistant message). Use a native Anthropic transport provider."
165
+ )
166
+
167
+
168
+ class AnthropicToOpenAIConverter:
169
+ """Convert Anthropic message format to OpenAI-compatible format."""
170
+
171
+ @staticmethod
172
+ def convert_messages(
173
+ messages: list[Any],
174
+ *,
175
+ reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS,
176
+ ) -> list[dict[str, Any]]:
177
+ result: list[dict[str, Any]] = []
178
+ pending: _PendingAfterTools | None = None
179
+
180
+ for msg in messages:
181
+ role = msg.role
182
+ content = msg.content
183
+ reasoning_content = _clean_reasoning_content(
184
+ getattr(msg, "reasoning_content", None)
185
+ )
186
+
187
+ if role == "assistant" and isinstance(content, list):
188
+ if pending is not None and pending.needs_deferred():
189
+ # Orphan: expected tool result; emit deferred to avoid a stuck session.
190
+ result.extend(
191
+ AnthropicToOpenAIConverter._deferred_post_tool_to_messages(
192
+ pending,
193
+ )
194
+ )
195
+ pending.deferred_emitted = True
196
+ pending = None
197
+
198
+ if (first_i := _index_first_tool_use(content)) is not None:
199
+ for block in content:
200
+ if get_block_type(block) == "tool_use":
201
+ continue
202
+ _assert_no_forbidden_assistant_block(block)
203
+ out, new_pending = (
204
+ AnthropicToOpenAIConverter._convert_assistant_message_with_split(
205
+ content,
206
+ first_tool_index=first_i,
207
+ reasoning_content=reasoning_content,
208
+ reasoning_replay=reasoning_replay,
209
+ )
210
+ )
211
+ result.extend(out)
212
+ if new_pending is not None:
213
+ pending = new_pending
214
+ else:
215
+ for block in content:
216
+ _assert_no_forbidden_assistant_block(block)
217
+ result.extend(
218
+ AnthropicToOpenAIConverter._convert_assistant_message(
219
+ content,
220
+ reasoning_content=reasoning_content,
221
+ reasoning_replay=reasoning_replay,
222
+ )
223
+ )
224
+ elif isinstance(content, str):
225
+ if role == "user" and pending is not None and pending.needs_deferred():
226
+ result.extend(
227
+ AnthropicToOpenAIConverter._deferred_post_tool_to_messages(
228
+ pending
229
+ )
230
+ )
231
+ pending.deferred_emitted = True
232
+ pending = None
233
+ converted = {"role": role, "content": content}
234
+ if role == "assistant" and reasoning_content:
235
+ if reasoning_replay == ReasoningReplayMode.REASONING_CONTENT:
236
+ converted["reasoning_content"] = reasoning_content
237
+ elif reasoning_replay == ReasoningReplayMode.THINK_TAGS:
238
+ content_parts = [_think_tag_content(reasoning_content)]
239
+ if content:
240
+ content_parts.append(content)
241
+ converted["content"] = "\n\n".join(content_parts)
242
+ result.append(converted)
243
+ elif isinstance(content, list):
244
+ if role == "user":
245
+ if pending is not None and pending.needs_deferred():
246
+ if not pending.remaining_tool_ids:
247
+ result.extend(
248
+ AnthropicToOpenAIConverter._deferred_post_tool_to_messages(
249
+ pending
250
+ )
251
+ )
252
+ pending.deferred_emitted = True
253
+ pending = None
254
+ result.extend(
255
+ AnthropicToOpenAIConverter._convert_user_message(
256
+ content
257
+ )
258
+ )
259
+ else:
260
+ pieces = AnthropicToOpenAIConverter._convert_user_message_with_injection(
261
+ content, pending
262
+ )
263
+ result.extend(pieces["messages"])
264
+ if pieces["cleared_pending"]:
265
+ pending = None
266
+ else:
267
+ result.extend(
268
+ AnthropicToOpenAIConverter._convert_user_message(content)
269
+ )
270
+ else:
271
+ if role == "user" and pending is not None and pending.needs_deferred():
272
+ result.extend(
273
+ AnthropicToOpenAIConverter._deferred_post_tool_to_messages(
274
+ pending
275
+ )
276
+ )
277
+ pending.deferred_emitted = True
278
+ pending = None
279
+ result.append({"role": role, "content": str(content)})
280
+
281
+ if pending is not None and pending.needs_deferred():
282
+ result.extend(
283
+ AnthropicToOpenAIConverter._deferred_post_tool_to_messages(pending)
284
+ )
285
+
286
+ return result
287
+
288
+ @staticmethod
289
+ def _convert_assistant_message_with_split(
290
+ content: list[Any],
291
+ *,
292
+ first_tool_index: int,
293
+ reasoning_content: str | None,
294
+ reasoning_replay: ReasoningReplayMode,
295
+ ) -> tuple[list[dict[str, Any]], _PendingAfterTools | None]:
296
+ pre = content[:first_tool_index]
297
+ tool_calls = _iter_tool_uses_in_order(content)
298
+ if not tool_calls:
299
+ return (
300
+ AnthropicToOpenAIConverter._convert_assistant_message(
301
+ content,
302
+ reasoning_content=reasoning_content,
303
+ reasoning_replay=reasoning_replay,
304
+ ),
305
+ None,
306
+ )
307
+ deferred_blocks = _deferred_post_tool_blocks(
308
+ content, first_tool_index=first_tool_index
309
+ )
310
+
311
+ pre_msg: dict[str, Any]
312
+ if not pre:
313
+ pre_msg = {
314
+ "role": "assistant",
315
+ "content": "",
316
+ }
317
+ if reasoning_replay == ReasoningReplayMode.REASONING_CONTENT:
318
+ replay = reasoning_content
319
+ if replay:
320
+ pre_msg["reasoning_content"] = replay
321
+ else:
322
+ pre_msg = AnthropicToOpenAIConverter._convert_assistant_message(
323
+ pre,
324
+ reasoning_content=reasoning_content,
325
+ reasoning_replay=reasoning_replay,
326
+ )[0]
327
+ pre_msg["tool_calls"] = tool_calls
328
+ if tool_calls and pre_msg.get("content") == " ":
329
+ pre_msg["content"] = ""
330
+ pnd: _PendingAfterTools | None = None
331
+ if deferred_blocks:
332
+ res_ids: set[str] = set()
333
+ for tc in tool_calls:
334
+ tid = tc.get("id")
335
+ if tid is not None and str(tid).strip() != "":
336
+ res_ids.add(str(tid))
337
+ pnd = _PendingAfterTools(
338
+ remaining_tool_ids=res_ids,
339
+ deferred_blocks=deferred_blocks,
340
+ top_level_reasoning=reasoning_content,
341
+ reasoning_replay=reasoning_replay,
342
+ )
343
+ return [pre_msg], pnd
344
+
345
+ @staticmethod
346
+ def _convert_assistant_message(
347
+ content: list[Any],
348
+ *,
349
+ reasoning_content: str | None = None,
350
+ reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS,
351
+ ) -> list[dict[str, Any]]:
352
+ content_parts: list[str] = []
353
+ thinking_parts: list[str] = []
354
+ tool_calls: list[dict[str, Any]] = []
355
+ for block in content:
356
+ block_type = get_block_type(block)
357
+ if block_type == "text":
358
+ content_parts.append(get_block_attr(block, "text", ""))
359
+ elif block_type == "thinking":
360
+ if reasoning_replay == ReasoningReplayMode.DISABLED:
361
+ continue
362
+ thinking = get_block_attr(block, "thinking", "")
363
+ if reasoning_replay == ReasoningReplayMode.THINK_TAGS:
364
+ content_parts.append(_think_tag_content(thinking))
365
+ elif reasoning_content is None:
366
+ thinking_parts.append(thinking)
367
+ elif block_type == "redacted_thinking":
368
+ # Opaque provider continuation data; do not materialize as model-visible text
369
+ # or reasoning_content for OpenAI chat upstreams.
370
+ continue
371
+ elif block_type == "tool_use":
372
+ tool_calls.append(_tool_call_from_tool_use(block))
373
+ else:
374
+ _assert_no_forbidden_assistant_block(block)
375
+
376
+ content_str = "\n\n".join(content_parts)
377
+ if not content_str and not tool_calls:
378
+ content_str = " "
379
+
380
+ msg: dict[str, Any] = {
381
+ "role": "assistant",
382
+ "content": content_str,
383
+ }
384
+ if tool_calls:
385
+ msg["tool_calls"] = tool_calls
386
+ if reasoning_replay == ReasoningReplayMode.REASONING_CONTENT:
387
+ replay_reasoning = reasoning_content or "\n".join(thinking_parts)
388
+ if replay_reasoning:
389
+ msg["reasoning_content"] = replay_reasoning
390
+
391
+ return [msg]
392
+
393
+ @staticmethod
394
+ def _deferred_post_tool_to_messages(
395
+ pending: _PendingAfterTools,
396
+ ) -> list[dict[str, Any]]:
397
+ if not pending.deferred_blocks:
398
+ return []
399
+ return AnthropicToOpenAIConverter._convert_assistant_message(
400
+ pending.deferred_blocks,
401
+ reasoning_content=pending.top_level_reasoning,
402
+ reasoning_replay=pending.reasoning_replay,
403
+ )
404
+
405
+ @staticmethod
406
+ def _convert_user_message_with_injection(
407
+ content: list[Any], pending: _PendingAfterTools
408
+ ) -> dict[str, Any]:
409
+ """Convert user list blocks, emitting deferred assistant after all tool results."""
410
+ if not pending.needs_deferred() or not pending.remaining_tool_ids:
411
+ return {
412
+ "messages": AnthropicToOpenAIConverter._convert_user_message(content),
413
+ "cleared_pending": False,
414
+ }
415
+
416
+ result: list[dict[str, Any]] = []
417
+ text_parts: list[str] = []
418
+ cleared = False
419
+
420
+ def flush_text() -> None:
421
+ if text_parts:
422
+ result.append({"role": "user", "content": "\n".join(text_parts)})
423
+ text_parts.clear()
424
+
425
+ for block in content:
426
+ block_type = get_block_type(block)
427
+ if block_type == "text":
428
+ text_parts.append(get_block_attr(block, "text", ""))
429
+ elif block_type == "image":
430
+ raise OpenAIConversionError(
431
+ "User message image blocks are not supported for OpenAI chat "
432
+ "conversion; use a vision-capable native Anthropic provider or "
433
+ "extend the converter."
434
+ )
435
+ elif block_type == "tool_result":
436
+ flush_text()
437
+ tool_content = get_block_attr(block, "content", "")
438
+ serialized = _serialize_tool_result_content(tool_content)
439
+ tuid = get_block_attr(block, "tool_use_id")
440
+ tuid_s = str(tuid) if tuid is not None else ""
441
+ result.append(
442
+ {
443
+ "role": "tool",
444
+ "tool_call_id": tuid,
445
+ "content": serialized if serialized else "",
446
+ }
447
+ )
448
+ if tuid_s in pending.remaining_tool_ids:
449
+ pending.remaining_tool_ids.discard(tuid_s)
450
+ if not pending.remaining_tool_ids:
451
+ result.extend(
452
+ AnthropicToOpenAIConverter._deferred_post_tool_to_messages(
453
+ pending
454
+ )
455
+ )
456
+ pending.deferred_emitted = True
457
+ cleared = True
458
+ else:
459
+ pass
460
+
461
+ flush_text()
462
+ return {"messages": result, "cleared_pending": cleared}
463
+
464
+ @staticmethod
465
+ def _convert_user_message(content: list[Any]) -> list[dict[str, Any]]:
466
+ result: list[dict[str, Any]] = []
467
+ text_parts: list[str] = []
468
+
469
+ def flush_text() -> None:
470
+ if text_parts:
471
+ result.append({"role": "user", "content": "\n".join(text_parts)})
472
+ text_parts.clear()
473
+
474
+ for block in content:
475
+ block_type = get_block_type(block)
476
+
477
+ if block_type == "text":
478
+ text_parts.append(get_block_attr(block, "text", ""))
479
+ elif block_type == "image":
480
+ raise OpenAIConversionError(
481
+ "User message image blocks are not supported for OpenAI chat "
482
+ "conversion; use a vision-capable native Anthropic provider or "
483
+ "extend the converter."
484
+ )
485
+ elif block_type == "tool_result":
486
+ flush_text()
487
+ tool_content = get_block_attr(block, "content", "")
488
+ serialized = _serialize_tool_result_content(tool_content)
489
+ result.append(
490
+ {
491
+ "role": "tool",
492
+ "tool_call_id": get_block_attr(block, "tool_use_id"),
493
+ "content": serialized if serialized else "",
494
+ }
495
+ )
496
+
497
+ flush_text()
498
+ return result
499
+
500
+ @staticmethod
501
+ def convert_tools(tools: list[Any]) -> list[dict[str, Any]]:
502
+ return [
503
+ {
504
+ "type": "function",
505
+ "function": {
506
+ "name": tool.name,
507
+ "description": tool.description or "",
508
+ "parameters": _tool_input_schema(tool),
509
+ },
510
+ }
511
+ for tool in tools
512
+ ]
513
+
514
+ @staticmethod
515
+ def convert_tool_choice(tool_choice: Any) -> Any:
516
+ if not isinstance(tool_choice, dict):
517
+ return tool_choice
518
+
519
+ choice_type = tool_choice.get("type")
520
+ if choice_type == "tool":
521
+ name = tool_choice.get("name")
522
+ if name:
523
+ return {"type": "function", "function": {"name": name}}
524
+ if choice_type == "any":
525
+ return "required"
526
+ if choice_type in {"auto", "none", "required"}:
527
+ return choice_type
528
+ if choice_type == "function" and isinstance(tool_choice.get("function"), dict):
529
+ return tool_choice
530
+
531
+ return tool_choice
532
+
533
+ @staticmethod
534
+ def convert_system_prompt(system: Any) -> dict[str, str] | None:
535
+ if isinstance(system, str):
536
+ return {"role": "system", "content": system}
537
+ if isinstance(system, list):
538
+ text_parts = [
539
+ get_block_attr(block, "text", "")
540
+ for block in system
541
+ if get_block_type(block) == "text"
542
+ ]
543
+ if text_parts:
544
+ return {"role": "system", "content": "\n\n".join(text_parts).strip()}
545
+ return None
546
+
547
+
548
+ def build_base_request_body(
549
+ request_data: Any,
550
+ *,
551
+ default_max_tokens: int | None = None,
552
+ reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS,
553
+ ) -> dict[str, Any]:
554
+ """Build the common parts of an OpenAI-format request body."""
555
+ _openai_reject_native_only_top_level_fields(request_data)
556
+ messages = AnthropicToOpenAIConverter.convert_messages(
557
+ request_data.messages,
558
+ reasoning_replay=reasoning_replay,
559
+ )
560
+
561
+ system = getattr(request_data, "system", None)
562
+ if system:
563
+ system_msg = AnthropicToOpenAIConverter.convert_system_prompt(system)
564
+ if system_msg:
565
+ messages.insert(0, system_msg)
566
+
567
+ body: dict[str, Any] = {"model": request_data.model, "messages": messages}
568
+
569
+ max_tokens = getattr(request_data, "max_tokens", None)
570
+ set_if_not_none(body, "max_tokens", max_tokens or default_max_tokens)
571
+ set_if_not_none(body, "temperature", getattr(request_data, "temperature", None))
572
+ set_if_not_none(body, "top_p", getattr(request_data, "top_p", None))
573
+
574
+ stop_sequences = getattr(request_data, "stop_sequences", None)
575
+ if stop_sequences:
576
+ body["stop"] = stop_sequences
577
+
578
+ tools = getattr(request_data, "tools", None)
579
+ if tools:
580
+ body["tools"] = AnthropicToOpenAIConverter.convert_tools(tools)
581
+ tool_choice = getattr(request_data, "tool_choice", None)
582
+ if tool_choice:
583
+ body["tool_choice"] = AnthropicToOpenAIConverter.convert_tool_choice(
584
+ tool_choice
585
+ )
586
+
587
+ return body