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,384 @@
1
+ """Per-request OpenAI-chat stream runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import uuid
7
+ from collections.abc import AsyncIterator, Iterator
8
+ from typing import Any
9
+
10
+ from loguru import logger
11
+
12
+ from core.anthropic import (
13
+ ContentType,
14
+ HeuristicToolParser,
15
+ SSEBuilder,
16
+ ThinkTagParser,
17
+ map_stop_reason,
18
+ )
19
+ from core.anthropic.stream_recovery import TruncatedProviderStreamError
20
+ from core.anthropic.stream_recovery_session import (
21
+ StreamFailureAction,
22
+ StreamRecoverySession,
23
+ )
24
+ from core.trace import provider_chat_body_snapshot, trace_event
25
+ from providers.error_mapping import map_error
26
+
27
+ from .recovery import OpenAIChatRecovery
28
+ from .tool_calls import (
29
+ OpenAIToolCallAssembler,
30
+ all_started_tools_complete,
31
+ has_committed_sse_output,
32
+ iter_heuristic_tool_use_sse,
33
+ tool_call_extra_content,
34
+ )
35
+
36
+
37
+ class OpenAIChatStreamRunner:
38
+ """Own mutable state for one OpenAI-chat provider stream."""
39
+
40
+ def __init__(
41
+ self,
42
+ transport: Any,
43
+ *,
44
+ request: Any,
45
+ input_tokens: int,
46
+ request_id: str | None,
47
+ thinking_enabled: bool | None,
48
+ ) -> None:
49
+ self._transport = transport
50
+ self._request = request
51
+ self._input_tokens = input_tokens
52
+ self._request_id = request_id
53
+ self._thinking_enabled = thinking_enabled
54
+ self._message_id = f"msg_{uuid.uuid4()}"
55
+ self._tool_calls = OpenAIToolCallAssembler(
56
+ record_extra_content=transport._record_tool_call_extra_content
57
+ )
58
+ self._recovery = OpenAIChatRecovery(
59
+ provider_name=transport._provider_name,
60
+ create_stream=transport._create_stream,
61
+ )
62
+
63
+ async def run(self) -> AsyncIterator[str]:
64
+ """Stream response in Anthropic SSE format."""
65
+ tag = self._transport._provider_name
66
+ req_tag = f" request_id={self._request_id}" if self._request_id else ""
67
+ sse = self._new_sse_builder()
68
+ recovery_session = StreamRecoverySession(
69
+ provider_name=tag,
70
+ request_id=self._request_id,
71
+ )
72
+
73
+ def hold_event(event: str) -> Iterator[str]:
74
+ yield from recovery_session.push(event)
75
+
76
+ def hold_events(events: Iterator[str]) -> Iterator[str]:
77
+ for event in events:
78
+ yield from hold_event(event)
79
+
80
+ body = self._transport._build_request_body(
81
+ self._request, thinking_enabled=self._thinking_enabled
82
+ )
83
+ thinking_enabled = self._transport._is_thinking_enabled(
84
+ self._request, self._thinking_enabled
85
+ )
86
+ trace_event(
87
+ stage="provider",
88
+ event="provider.request.sent",
89
+ source="provider",
90
+ provider=tag,
91
+ gateway_model=self._request.model,
92
+ downstream_model=body.get("model"),
93
+ message_count=len(body.get("messages", [])),
94
+ tool_count=len(body.get("tools", [])),
95
+ body=provider_chat_body_snapshot(body),
96
+ )
97
+
98
+ yield sse.message_start()
99
+
100
+ think_parser = ThinkTagParser()
101
+ heuristic_parser = HeuristicToolParser()
102
+ finish_reason = None
103
+ usage_info = None
104
+ tool_argument_aliases: dict[str, dict[str, str]] = {}
105
+ tool_argument_alias_buffers: dict[int, str] = {}
106
+
107
+ async with self._transport._global_rate_limiter.concurrency_slot():
108
+ while True:
109
+ stream_opened = False
110
+ try:
111
+ stream, body = await self._transport._create_stream(body)
112
+ stream_opened = True
113
+ tool_argument_aliases = self._transport._tool_argument_aliases(body)
114
+ async for chunk in stream:
115
+ if getattr(chunk, "usage", None):
116
+ usage_info = chunk.usage
117
+
118
+ if not chunk.choices:
119
+ continue
120
+
121
+ choice = chunk.choices[0]
122
+ delta = choice.delta
123
+ if delta is None:
124
+ continue
125
+
126
+ if choice.finish_reason:
127
+ finish_reason = choice.finish_reason
128
+ logger.debug("{} finish_reason: {}", tag, finish_reason)
129
+
130
+ reasoning = getattr(delta, "reasoning_content", None)
131
+ if thinking_enabled and reasoning:
132
+ for event in hold_events(sse.ensure_thinking_block()):
133
+ yield event
134
+ for event in hold_event(sse.emit_thinking_delta(reasoning)):
135
+ yield event
136
+
137
+ for event in self._transport._handle_extra_reasoning(
138
+ delta,
139
+ sse,
140
+ thinking_enabled=thinking_enabled,
141
+ ):
142
+ for out_event in hold_event(event):
143
+ yield out_event
144
+
145
+ if delta.content:
146
+ for part in think_parser.feed(delta.content):
147
+ if part.type == ContentType.THINKING:
148
+ if not thinking_enabled:
149
+ continue
150
+ for event in hold_events(
151
+ sse.ensure_thinking_block()
152
+ ):
153
+ yield event
154
+ for event in hold_event(
155
+ sse.emit_thinking_delta(part.content)
156
+ ):
157
+ yield event
158
+ else:
159
+ (
160
+ filtered_text,
161
+ detected_tools,
162
+ ) = heuristic_parser.feed(part.content)
163
+
164
+ if filtered_text:
165
+ for event in hold_events(
166
+ sse.ensure_text_block()
167
+ ):
168
+ yield event
169
+ for event in hold_event(
170
+ sse.emit_text_delta(filtered_text)
171
+ ):
172
+ yield event
173
+
174
+ for tool_use in detected_tools:
175
+ for event in iter_heuristic_tool_use_sse(
176
+ sse, tool_use
177
+ ):
178
+ for out_event in hold_event(event):
179
+ yield out_event
180
+
181
+ if delta.tool_calls:
182
+ for event in hold_events(sse.close_content_blocks()):
183
+ yield event
184
+ for tc in delta.tool_calls:
185
+ extra_content = tool_call_extra_content(tc)
186
+ tc_info = {
187
+ "index": tc.index,
188
+ "id": tc.id,
189
+ "function": {
190
+ "name": tc.function.name,
191
+ "arguments": tc.function.arguments,
192
+ },
193
+ }
194
+ if extra_content:
195
+ tc_info["extra_content"] = extra_content
196
+ for event in self._tool_calls.process_tool_call(
197
+ tc_info,
198
+ sse,
199
+ tool_argument_aliases=tool_argument_aliases,
200
+ tool_argument_alias_buffers=tool_argument_alias_buffers,
201
+ ):
202
+ for out_event in hold_event(event):
203
+ yield out_event
204
+
205
+ if finish_reason is None:
206
+ raise TruncatedProviderStreamError(
207
+ "Provider stream ended without finish_reason."
208
+ )
209
+ break
210
+
211
+ except asyncio.CancelledError, GeneratorExit:
212
+ raise
213
+ except Exception as error:
214
+ generated_output = has_committed_sse_output(sse)
215
+ complete_tool_salvageable = (
216
+ generated_output
217
+ and sse.blocks.has_emitted_tool_block()
218
+ and all_started_tools_complete(sse, self._request)
219
+ )
220
+ decision = recovery_session.advance_failure(
221
+ error,
222
+ stream_opened=stream_opened,
223
+ generated_output=generated_output,
224
+ complete_tool_salvageable=complete_tool_salvageable,
225
+ )
226
+ if decision.action == StreamFailureAction.EARLY_RETRY:
227
+ sse = self._new_sse_builder()
228
+ think_parser = ThinkTagParser()
229
+ heuristic_parser = HeuristicToolParser()
230
+ finish_reason = None
231
+ usage_info = None
232
+ tool_argument_aliases = {}
233
+ tool_argument_alias_buffers = {}
234
+ continue
235
+
236
+ if decision.action == StreamFailureAction.MIDSTREAM_RECOVERY:
237
+ try:
238
+ recovery_events = await self._recovery.events(
239
+ body=body,
240
+ sse=sse,
241
+ request=self._request,
242
+ request_id=self._request_id,
243
+ error=error,
244
+ tool_argument_alias_buffers=tool_argument_alias_buffers,
245
+ )
246
+ except Exception as recovery_error:
247
+ trace_event(
248
+ stage="provider",
249
+ event="provider.recovery.failed",
250
+ source="provider",
251
+ provider=tag,
252
+ request_id=self._request_id,
253
+ exc_type=type(recovery_error).__name__,
254
+ )
255
+ recovery_events = None
256
+ if recovery_events is not None:
257
+ for event in recovery_session.flush_uncommitted(decision):
258
+ yield event
259
+ for event in recovery_events:
260
+ yield event
261
+ return
262
+
263
+ self._transport._log_stream_transport_error(
264
+ tag, req_tag, error, request_id=self._request_id
265
+ )
266
+ error_message = self._transport._openai_error_message(
267
+ error, self._request_id
268
+ )
269
+ trace_event(
270
+ stage="provider",
271
+ event="provider.response.error",
272
+ source="provider",
273
+ provider=tag,
274
+ error_message=error_message,
275
+ mapped_error_type=type(
276
+ map_error(
277
+ error,
278
+ rate_limiter=self._transport._global_rate_limiter,
279
+ )
280
+ ).__name__,
281
+ )
282
+ if not decision.committed and decision.has_buffered:
283
+ for event in recovery_session.flush():
284
+ yield event
285
+ elif not decision.committed:
286
+ recovery_session.discard()
287
+ sse = self._new_sse_builder()
288
+ for event in self._recovery.emit_error_tail(sse, error_message):
289
+ yield event
290
+ return
291
+
292
+ remaining = think_parser.flush()
293
+ if remaining:
294
+ if remaining.type == ContentType.THINKING:
295
+ if not thinking_enabled:
296
+ remaining = None
297
+ else:
298
+ for event in hold_events(sse.ensure_thinking_block()):
299
+ yield event
300
+ for event in hold_event(sse.emit_thinking_delta(remaining.content)):
301
+ yield event
302
+ if remaining and remaining.type == ContentType.TEXT:
303
+ for event in hold_events(sse.ensure_text_block()):
304
+ yield event
305
+ for event in hold_event(sse.emit_text_delta(remaining.content)):
306
+ yield event
307
+
308
+ for tool_use in heuristic_parser.flush():
309
+ for event in iter_heuristic_tool_use_sse(sse, tool_use):
310
+ for out_event in hold_event(event):
311
+ yield out_event
312
+
313
+ has_started_tool = any(s.started for s in sse.blocks.tool_states.values())
314
+ has_content_blocks = (
315
+ sse.blocks.text_index != -1
316
+ or sse.blocks.thinking_index != -1
317
+ or has_started_tool
318
+ )
319
+ if not has_content_blocks or (
320
+ not has_started_tool
321
+ and not sse.accumulated_text.strip()
322
+ and sse.accumulated_reasoning.strip()
323
+ ):
324
+ for event in hold_events(sse.ensure_text_block()):
325
+ yield event
326
+ for event in hold_event(sse.emit_text_delta(" ")):
327
+ yield event
328
+
329
+ for event in self._tool_calls.flush_tool_argument_alias_buffers(
330
+ sse, tool_argument_aliases, tool_argument_alias_buffers
331
+ ):
332
+ for out_event in hold_event(event):
333
+ yield out_event
334
+
335
+ for event in self._tool_calls.flush_task_arg_buffers(sse):
336
+ for out_event in hold_event(event):
337
+ yield out_event
338
+
339
+ for event in hold_events(sse.close_all_blocks()):
340
+ yield event
341
+
342
+ completion = (
343
+ getattr(usage_info, "completion_tokens", None)
344
+ if usage_info is not None
345
+ else None
346
+ )
347
+ if isinstance(completion, int):
348
+ output_tokens = completion
349
+ else:
350
+ output_tokens = sse.estimate_output_tokens()
351
+ if usage_info and hasattr(usage_info, "prompt_tokens"):
352
+ provider_input = usage_info.prompt_tokens
353
+ if isinstance(provider_input, int):
354
+ logger.debug(
355
+ "TOKEN_ESTIMATE: our={} provider={} diff={:+d}",
356
+ self._input_tokens,
357
+ provider_input,
358
+ provider_input - self._input_tokens,
359
+ )
360
+ trace_event(
361
+ stage="provider",
362
+ event="provider.response.completed",
363
+ source="provider",
364
+ provider=tag,
365
+ finish_reason=(None if finish_reason is None else str(finish_reason)),
366
+ output_tokens=output_tokens,
367
+ prompt_tokens_estimate=self._input_tokens,
368
+ )
369
+ for event in hold_event(
370
+ sse.message_delta(map_stop_reason(finish_reason), output_tokens)
371
+ ):
372
+ yield event
373
+ for event in hold_event(sse.message_stop()):
374
+ yield event
375
+ for event in recovery_session.flush():
376
+ yield event
377
+
378
+ def _new_sse_builder(self) -> SSEBuilder:
379
+ return SSEBuilder(
380
+ self._message_id,
381
+ self._request.model,
382
+ self._input_tokens,
383
+ log_raw_events=self._transport._config.log_raw_sse_events,
384
+ )
@@ -0,0 +1,293 @@
1
+ """OpenAI-chat tool-call assembly helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from collections.abc import Callable, Iterator
8
+ from typing import Any
9
+
10
+ from core.anthropic import SSEBuilder
11
+ from core.anthropic.stream_recovery import (
12
+ parse_complete_tool_input,
13
+ tool_schemas_by_name,
14
+ )
15
+
16
+ RecordToolExtraContent = Callable[[str, dict[str, Any]], None]
17
+
18
+
19
+ def iter_heuristic_tool_use_sse(
20
+ sse: SSEBuilder, tool_use: dict[str, Any]
21
+ ) -> Iterator[str]:
22
+ """Emit SSE for one heuristic tool_use block."""
23
+ if tool_use.get("name") == "Task" and isinstance(tool_use.get("input"), dict):
24
+ task_input = tool_use["input"]
25
+ if task_input.get("run_in_background") is not False:
26
+ task_input["run_in_background"] = False
27
+ yield from sse.close_content_blocks()
28
+ block_idx = sse.blocks.allocate_index()
29
+ yield sse.content_block_start(
30
+ block_idx,
31
+ "tool_use",
32
+ id=tool_use["id"],
33
+ name=tool_use["name"],
34
+ )
35
+ yield sse.content_block_delta(
36
+ block_idx,
37
+ "input_json_delta",
38
+ json.dumps(tool_use["input"]),
39
+ )
40
+ yield sse.content_block_stop(block_idx)
41
+
42
+
43
+ def tool_call_extra_content(tool_call: Any) -> dict[str, Any] | None:
44
+ """Return provider-specific extra tool-call metadata from OpenAI objects."""
45
+ if isinstance(tool_call, dict):
46
+ value = tool_call.get("extra_content")
47
+ return value if isinstance(value, dict) else None
48
+
49
+ value = getattr(tool_call, "extra_content", None)
50
+ if isinstance(value, dict):
51
+ return value
52
+
53
+ model_extra = getattr(tool_call, "model_extra", None)
54
+ if isinstance(model_extra, dict):
55
+ value = model_extra.get("extra_content")
56
+ if isinstance(value, dict):
57
+ return value
58
+
59
+ pydantic_extra = getattr(tool_call, "__pydantic_extra__", None)
60
+ if isinstance(pydantic_extra, dict):
61
+ value = pydantic_extra.get("extra_content")
62
+ if isinstance(value, dict):
63
+ return value
64
+
65
+ return None
66
+
67
+
68
+ def has_committed_sse_output(sse: SSEBuilder) -> bool:
69
+ """Return whether any assistant content escaped the builder."""
70
+ return (
71
+ sse.blocks.text_index != -1
72
+ or sse.blocks.thinking_index != -1
73
+ or sse.blocks.has_emitted_tool_block()
74
+ )
75
+
76
+
77
+ def started_tool_states(sse: SSEBuilder) -> list[tuple[int, Any]]:
78
+ """Return started tool states in stream order."""
79
+ return [
80
+ (tool_index, state)
81
+ for tool_index, state in sse.blocks.tool_states.items()
82
+ if state.started
83
+ ]
84
+
85
+
86
+ def all_started_tools_complete(sse: SSEBuilder, request: Any) -> bool:
87
+ """Return whether every emitted tool block has schema-valid input."""
88
+ schemas = tool_schemas_by_name(request)
89
+ started = started_tool_states(sse)
90
+ if not started:
91
+ return False
92
+ for _, state in started:
93
+ raw = "".join(state.contents)
94
+ if parse_complete_tool_input(raw, state.name, schemas) is None:
95
+ return False
96
+ return True
97
+
98
+
99
+ class OpenAIToolCallAssembler:
100
+ """Assemble OpenAI tool-call deltas into Anthropic SSE tool blocks."""
101
+
102
+ def __init__(
103
+ self, *, record_extra_content: RecordToolExtraContent | None = None
104
+ ) -> None:
105
+ self._record_extra_content = record_extra_content
106
+
107
+ def process_tool_call(
108
+ self,
109
+ tc: dict[str, Any],
110
+ sse: SSEBuilder,
111
+ *,
112
+ tool_argument_aliases: dict[str, dict[str, str]] | None = None,
113
+ tool_argument_alias_buffers: dict[int, str] | None = None,
114
+ ) -> Iterator[str]:
115
+ """Process a single tool-call delta and yield Anthropic SSE events."""
116
+ raw_index = tc.get("index", 0)
117
+ tc_index = raw_index if isinstance(raw_index, int) else 0
118
+ if tc_index < 0:
119
+ tc_index = len(sse.blocks.tool_states)
120
+
121
+ fn_delta = tc.get("function", {})
122
+ incoming_name = fn_delta.get("name")
123
+ arguments = fn_delta.get("arguments", "") or ""
124
+
125
+ if tc.get("id") is not None:
126
+ sse.blocks.set_stream_tool_id(tc_index, tc.get("id"))
127
+
128
+ raw_extra_content = tc.get("extra_content")
129
+ extra_content = (
130
+ raw_extra_content
131
+ if isinstance(raw_extra_content, dict) and raw_extra_content
132
+ else None
133
+ )
134
+ if extra_content:
135
+ sse.blocks.set_tool_extra_content(tc_index, extra_content)
136
+
137
+ if incoming_name is not None:
138
+ sse.blocks.register_tool_name(tc_index, incoming_name)
139
+
140
+ state = sse.blocks.tool_states.get(tc_index)
141
+ resolved_id = (state.tool_id if state and state.tool_id else None) or tc.get(
142
+ "id"
143
+ )
144
+ resolved_name = (state.name if state else "") or ""
145
+
146
+ if not state or not state.started:
147
+ name_ok = bool((resolved_name or "").strip())
148
+ if name_ok:
149
+ tool_id = str(resolved_id) if resolved_id else f"tool_{uuid.uuid4()}"
150
+ display_name = (resolved_name or "").strip() or "tool_call"
151
+ start_extra_content = state.extra_content if state else extra_content
152
+ if start_extra_content:
153
+ self._record_tool_call_extra_content(tool_id, start_extra_content)
154
+ yield sse.start_tool_block(
155
+ tc_index,
156
+ tool_id,
157
+ display_name,
158
+ extra_content=start_extra_content,
159
+ )
160
+ state = sse.blocks.tool_states[tc_index]
161
+ if state.pre_start_args:
162
+ pre = state.pre_start_args
163
+ state.pre_start_args = ""
164
+ yield from self._emit_tool_arg_delta(
165
+ sse,
166
+ tc_index,
167
+ pre,
168
+ tool_argument_aliases=tool_argument_aliases,
169
+ tool_argument_alias_buffers=tool_argument_alias_buffers,
170
+ )
171
+
172
+ state = sse.blocks.tool_states.get(tc_index)
173
+ if state is not None and state.tool_id and extra_content:
174
+ self._record_tool_call_extra_content(state.tool_id, extra_content)
175
+ if not arguments:
176
+ return
177
+ if state is None or not state.started:
178
+ state = sse.blocks.ensure_tool_state(tc_index)
179
+ if not (resolved_name or "").strip():
180
+ state.pre_start_args += arguments
181
+ return
182
+
183
+ yield from self._emit_tool_arg_delta(
184
+ sse,
185
+ tc_index,
186
+ arguments,
187
+ tool_argument_aliases=tool_argument_aliases,
188
+ tool_argument_alias_buffers=tool_argument_alias_buffers,
189
+ )
190
+
191
+ def flush_task_arg_buffers(self, sse: SSEBuilder) -> Iterator[str]:
192
+ """Emit buffered Task args as a single JSON delta."""
193
+ for tool_index, out in sse.blocks.flush_task_arg_buffers():
194
+ yield sse.emit_tool_delta(tool_index, out)
195
+
196
+ def flush_tool_argument_alias_buffers(
197
+ self,
198
+ sse: SSEBuilder,
199
+ tool_argument_aliases: dict[str, dict[str, str]],
200
+ tool_argument_alias_buffers: dict[int, str],
201
+ ) -> Iterator[str]:
202
+ """Emit remaining aliased args without losing malformed JSON."""
203
+ for tool_index, buffered_args in list(tool_argument_alias_buffers.items()):
204
+ if not buffered_args:
205
+ tool_argument_alias_buffers.pop(tool_index, None)
206
+ continue
207
+ state = sse.blocks.tool_states.get(tool_index)
208
+ if state is None or state.name == "Task":
209
+ continue
210
+ aliases = tool_argument_aliases.get(state.name, {})
211
+ if not aliases:
212
+ continue
213
+ restored = self._restore_aliased_tool_arguments(buffered_args, aliases)
214
+ yield sse.emit_tool_delta(
215
+ tool_index,
216
+ restored if restored is not None else buffered_args,
217
+ )
218
+ tool_argument_alias_buffers.pop(tool_index, None)
219
+
220
+ def _emit_tool_arg_delta(
221
+ self,
222
+ sse: SSEBuilder,
223
+ tc_index: int,
224
+ args: str,
225
+ *,
226
+ tool_argument_aliases: dict[str, dict[str, str]] | None = None,
227
+ tool_argument_alias_buffers: dict[int, str] | None = None,
228
+ ) -> Iterator[str]:
229
+ """Emit one argument fragment for a started tool block."""
230
+ if not args:
231
+ return
232
+ state = sse.blocks.tool_states.get(tc_index)
233
+ if state is None:
234
+ return
235
+ if state.name == "Task":
236
+ parsed = sse.blocks.buffer_task_args(tc_index, args)
237
+ if parsed is not None:
238
+ yield sse.emit_tool_delta(tc_index, json.dumps(parsed))
239
+ return
240
+ aliases = (
241
+ tool_argument_aliases.get(state.name, {}) if tool_argument_aliases else {}
242
+ )
243
+ if aliases:
244
+ if tool_argument_alias_buffers is None:
245
+ restored = self._restore_aliased_tool_arguments(args, aliases)
246
+ if restored is not None:
247
+ yield sse.emit_tool_delta(tc_index, restored)
248
+ return
249
+
250
+ buffered_args = tool_argument_alias_buffers.get(tc_index, "") + args
251
+ restored = self._restore_aliased_tool_arguments(buffered_args, aliases)
252
+ if restored is None:
253
+ tool_argument_alias_buffers[tc_index] = buffered_args
254
+ return
255
+ tool_argument_alias_buffers.pop(tc_index, None)
256
+ yield sse.emit_tool_delta(tc_index, restored)
257
+ return
258
+ yield sse.emit_tool_delta(tc_index, args)
259
+
260
+ def _restore_aliased_tool_arguments(
261
+ self, argument_json: str, aliases: dict[str, str]
262
+ ) -> str | None:
263
+ try:
264
+ parsed = json.loads(argument_json)
265
+ except json.JSONDecodeError:
266
+ return None
267
+ if not isinstance(parsed, dict):
268
+ return argument_json
269
+ restored = self._restore_aliased_tool_argument_value(parsed, aliases)
270
+ return json.dumps(restored)
271
+
272
+ def _restore_aliased_tool_argument_value(
273
+ self, value: Any, aliases: dict[str, str]
274
+ ) -> Any:
275
+ if isinstance(value, dict):
276
+ return {
277
+ aliases.get(key, key): self._restore_aliased_tool_argument_value(
278
+ item, aliases
279
+ )
280
+ for key, item in value.items()
281
+ }
282
+ if isinstance(value, list):
283
+ return [
284
+ self._restore_aliased_tool_argument_value(item, aliases)
285
+ for item in value
286
+ ]
287
+ return value
288
+
289
+ def _record_tool_call_extra_content(
290
+ self, tool_call_id: str, extra_content: dict[str, Any]
291
+ ) -> None:
292
+ if self._record_extra_content is not None:
293
+ self._record_extra_content(tool_call_id, extra_content)