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,236 @@
1
+ """Shared transport for providers with native Anthropic Messages endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Iterator
6
+ from typing import Any, Literal
7
+
8
+ import httpx
9
+
10
+ from config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS
11
+ from core.anthropic import iter_provider_stream_error_sse_events
12
+ from core.anthropic.native_messages_request import (
13
+ build_base_native_anthropic_request_body,
14
+ )
15
+ from core.anthropic.native_sse_block_policy import (
16
+ NativeSseBlockPolicyState,
17
+ transform_native_sse_block_event,
18
+ )
19
+ from providers.base import BaseProvider, ProviderConfig
20
+ from providers.error_mapping import (
21
+ extract_provider_error_detail,
22
+ map_error,
23
+ user_visible_message_for_mapped_provider_error,
24
+ )
25
+ from providers.model_listing import (
26
+ ProviderModelInfo,
27
+ extract_openai_model_ids,
28
+ model_infos_from_ids,
29
+ )
30
+ from providers.rate_limit import GlobalRateLimiter
31
+
32
+ from .http import maybe_await_aclose, model_list_json, raise_for_status_with_body
33
+ from .stream import AnthropicMessagesStreamRunner
34
+
35
+ StreamChunkMode = Literal["line", "event"]
36
+
37
+
38
+ class AnthropicMessagesTransport(BaseProvider):
39
+ """Base class for providers that stream from an Anthropic-compatible endpoint."""
40
+
41
+ stream_chunk_mode: StreamChunkMode = "line"
42
+
43
+ def __init__(
44
+ self,
45
+ config: ProviderConfig,
46
+ *,
47
+ provider_name: str,
48
+ default_base_url: str,
49
+ ):
50
+ super().__init__(config)
51
+ self._provider_name = provider_name
52
+ self._api_key = config.api_key
53
+ self._base_url = (config.base_url or default_base_url).rstrip("/")
54
+ self._global_rate_limiter = GlobalRateLimiter.get_scoped_instance(
55
+ provider_name.lower(),
56
+ rate_limit=config.rate_limit,
57
+ rate_window=config.rate_window,
58
+ max_concurrency=config.max_concurrency,
59
+ )
60
+ self._client = httpx.AsyncClient(
61
+ base_url=self._base_url,
62
+ proxy=config.proxy or None,
63
+ timeout=httpx.Timeout(
64
+ config.http_read_timeout,
65
+ connect=config.http_connect_timeout,
66
+ read=config.http_read_timeout,
67
+ write=config.http_write_timeout,
68
+ ),
69
+ )
70
+
71
+ async def cleanup(self) -> None:
72
+ """Release HTTP client resources."""
73
+ await self._client.aclose()
74
+
75
+ async def list_model_ids(self) -> frozenset[str]:
76
+ """Return model ids from an OpenAI-compatible ``/models`` endpoint."""
77
+ return frozenset(info.model_id for info in await self.list_model_infos())
78
+
79
+ async def list_model_infos(self) -> frozenset[ProviderModelInfo]:
80
+ """Return model ids plus optional metadata from a ``/models`` endpoint."""
81
+ response = await self._send_model_list_request()
82
+ try:
83
+ payload = model_list_json(response, provider_name=self._provider_name)
84
+ return self._extract_model_infos_from_model_list_payload(payload)
85
+ finally:
86
+ await maybe_await_aclose(response)
87
+
88
+ async def _send_model_list_request(self) -> httpx.Response:
89
+ """Query the provider endpoint that advertises available model ids."""
90
+ return await self._client.get(
91
+ "/models",
92
+ headers=self._model_list_headers(),
93
+ )
94
+
95
+ def _model_list_headers(self) -> dict[str, str]:
96
+ """Return headers for model-list requests."""
97
+ return {}
98
+
99
+ def _extract_model_ids_from_model_list_payload(
100
+ self, payload: Any
101
+ ) -> frozenset[str]:
102
+ """Parse the provider model-list response body."""
103
+ return extract_openai_model_ids(payload, provider_name=self._provider_name)
104
+
105
+ def _extract_model_infos_from_model_list_payload(
106
+ self, payload: Any
107
+ ) -> frozenset[ProviderModelInfo]:
108
+ """Parse provider model metadata; default to unknown capabilities."""
109
+ return model_infos_from_ids(
110
+ self._extract_model_ids_from_model_list_payload(payload)
111
+ )
112
+
113
+ def _request_headers(self) -> dict[str, str]:
114
+ """Return headers for the native messages request."""
115
+ return {"Content-Type": "application/json"}
116
+
117
+ def _build_request_body(
118
+ self, request: Any, thinking_enabled: bool | None = None
119
+ ) -> dict:
120
+ """Build a native Anthropic request body."""
121
+ thinking_enabled = self._is_thinking_enabled(request, thinking_enabled)
122
+ return self._build_request_body_with_resolved_thinking(
123
+ request,
124
+ thinking_enabled=thinking_enabled,
125
+ )
126
+
127
+ def _build_request_body_with_resolved_thinking(
128
+ self, request: Any, *, thinking_enabled: bool
129
+ ) -> dict:
130
+ """Build a native Anthropic request body after thinking is resolved."""
131
+ return build_base_native_anthropic_request_body(
132
+ request,
133
+ default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
134
+ thinking_enabled=thinking_enabled,
135
+ )
136
+
137
+ async def _send_stream_request(self, body: dict) -> httpx.Response:
138
+ """Create a streaming messages response."""
139
+ request = self._client.build_request(
140
+ "POST",
141
+ "/messages",
142
+ json=body,
143
+ headers=self._request_headers(),
144
+ )
145
+ return await self._client.send(request, stream=True)
146
+
147
+ async def _raise_for_status(
148
+ self, response: httpx.Response, *, req_tag: str
149
+ ) -> None:
150
+ """Raise for non-200 responses after attaching safe error metadata."""
151
+ await raise_for_status_with_body(
152
+ response,
153
+ provider_name=self._provider_name,
154
+ req_tag=req_tag,
155
+ log_api_error_tracebacks=self._config.log_api_error_tracebacks,
156
+ )
157
+
158
+ def _new_stream_state(self, request: Any, *, thinking_enabled: bool) -> Any:
159
+ """Return per-stream provider state for event transformation."""
160
+ if self.stream_chunk_mode == "line":
161
+ return NativeSseBlockPolicyState()
162
+ return None
163
+
164
+ def _transform_stream_event(
165
+ self,
166
+ event: str,
167
+ state: Any,
168
+ *,
169
+ thinking_enabled: bool,
170
+ ) -> str | None:
171
+ """Transform or drop a grouped SSE event before yielding it downstream."""
172
+ if isinstance(state, NativeSseBlockPolicyState):
173
+ return transform_native_sse_block_event(
174
+ event, state, thinking_enabled=thinking_enabled
175
+ )
176
+ return event
177
+
178
+ def _get_error_message(self, error: Exception, request_id: str | None) -> str:
179
+ """Map an exception into a user-facing provider error message."""
180
+ mapped_error = map_error(error, rate_limiter=self._global_rate_limiter)
181
+ return user_visible_message_for_mapped_provider_error(
182
+ mapped_error,
183
+ provider_name=self._provider_name,
184
+ read_timeout_s=self._config.http_read_timeout,
185
+ detail=extract_provider_error_detail(error),
186
+ request_id=request_id,
187
+ )
188
+
189
+ async def _validated_stream_send(
190
+ self, body: dict, *, req_tag: str
191
+ ) -> httpx.Response:
192
+ """Send request and raise mapped HTTP errors before yielding body chunks."""
193
+ send_response = await self._send_stream_request(body)
194
+ if send_response.status_code != 200:
195
+ try:
196
+ await self._raise_for_status(send_response, req_tag=req_tag)
197
+ finally:
198
+ if not send_response.is_closed:
199
+ await maybe_await_aclose(send_response)
200
+ return send_response
201
+
202
+ def _emit_error_events(
203
+ self,
204
+ *,
205
+ request: Any,
206
+ input_tokens: int,
207
+ error_message: str,
208
+ sent_any_event: bool,
209
+ ) -> Iterator[str]:
210
+ """Emit the same Anthropic message lifecycle used by OpenAI-chat providers."""
211
+ yield from iter_provider_stream_error_sse_events(
212
+ request=request,
213
+ input_tokens=input_tokens,
214
+ error_message=error_message,
215
+ sent_any_event=sent_any_event,
216
+ log_raw_sse_events=self._config.log_raw_sse_events,
217
+ )
218
+
219
+ async def stream_response(
220
+ self,
221
+ request: Any,
222
+ input_tokens: int = 0,
223
+ *,
224
+ request_id: str | None = None,
225
+ thinking_enabled: bool | None = None,
226
+ ) -> AsyncIterator[str]:
227
+ """Stream response via a native Anthropic-compatible messages endpoint."""
228
+ runner = AnthropicMessagesStreamRunner(
229
+ self,
230
+ request=request,
231
+ input_tokens=input_tokens,
232
+ request_id=request_id,
233
+ thinking_enabled=thinking_enabled,
234
+ )
235
+ async for event in runner.run():
236
+ yield event
@@ -0,0 +1,5 @@
1
+ """OpenAI-compatible chat transport family."""
2
+
3
+ from .transport import OpenAIChatTransport
4
+
5
+ __all__ = ["OpenAIChatTransport"]
@@ -0,0 +1,217 @@
1
+ """OpenAI-chat stream recovery event construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable, Iterator
6
+ from typing import Any
7
+
8
+ from core.anthropic import SSEBuilder
9
+ from core.anthropic.stream_recovery import (
10
+ MIDSTREAM_RECOVERY_ATTEMPTS,
11
+ accept_tool_json_repair,
12
+ continuation_suffix,
13
+ is_retryable_stream_error,
14
+ make_openai_text_recovery_body,
15
+ make_openai_tool_repair_body,
16
+ parse_complete_tool_input,
17
+ tool_schemas_by_name,
18
+ )
19
+ from core.trace import trace_event
20
+
21
+ from .tool_calls import all_started_tools_complete, started_tool_states
22
+
23
+ CreateStream = Callable[[dict[str, Any]], Awaitable[tuple[Any, dict[str, Any]]]]
24
+
25
+
26
+ class OpenAIChatRecovery:
27
+ """Construct recovery events for interrupted OpenAI-chat streams."""
28
+
29
+ def __init__(self, *, provider_name: str, create_stream: CreateStream) -> None:
30
+ self._provider_name = provider_name
31
+ self._create_stream = create_stream
32
+
33
+ async def collect_text(self, body: dict[str, Any]) -> tuple[str, str]:
34
+ """Collect text/reasoning from an internal recovery request."""
35
+ last_error: Exception | None = None
36
+ for attempt in range(MIDSTREAM_RECOVERY_ATTEMPTS):
37
+ try:
38
+ stream, _ = await self._create_stream(body)
39
+ text_parts: list[str] = []
40
+ thinking_parts: list[str] = []
41
+ async for chunk in stream:
42
+ if not getattr(chunk, "choices", None):
43
+ continue
44
+ choice = chunk.choices[0]
45
+ delta = choice.delta
46
+ if delta is None:
47
+ continue
48
+ reasoning = getattr(delta, "reasoning_content", None)
49
+ if isinstance(reasoning, str) and reasoning:
50
+ thinking_parts.append(reasoning)
51
+ content = getattr(delta, "content", None)
52
+ if isinstance(content, str) and content:
53
+ text_parts.append(content)
54
+ return "".join(text_parts), "".join(thinking_parts)
55
+ except Exception as error:
56
+ last_error = error
57
+ if not is_retryable_stream_error(error):
58
+ raise
59
+ trace_event(
60
+ stage="provider",
61
+ event="provider.recovery.retry",
62
+ source="provider",
63
+ provider=self._provider_name,
64
+ recovery_kind="openai_text",
65
+ attempt=attempt + 1,
66
+ max_attempts=MIDSTREAM_RECOVERY_ATTEMPTS,
67
+ exc_type=type(error).__name__,
68
+ )
69
+ if last_error is not None:
70
+ raise last_error
71
+ return "", ""
72
+
73
+ async def events(
74
+ self,
75
+ *,
76
+ body: dict[str, Any],
77
+ sse: SSEBuilder,
78
+ request: Any,
79
+ request_id: str | None,
80
+ error: Exception,
81
+ tool_argument_alias_buffers: dict[int, str],
82
+ ) -> list[str] | None:
83
+ """Build recovery events, or return None when recovery is impossible."""
84
+ if not is_retryable_stream_error(error):
85
+ return None
86
+
87
+ if sse.blocks.has_emitted_tool_block():
88
+ if not all_started_tools_complete(sse, request):
89
+ repair_events = await self._repair_tool_args(
90
+ body=body,
91
+ sse=sse,
92
+ request=request,
93
+ tool_argument_alias_buffers=tool_argument_alias_buffers,
94
+ )
95
+ if repair_events is None:
96
+ return None
97
+ else:
98
+ repair_events = []
99
+ events = list(repair_events)
100
+ events.extend(sse.close_all_blocks())
101
+ events.append(sse.message_delta("tool_use", sse.estimate_output_tokens()))
102
+ events.append(sse.message_stop())
103
+ trace_event(
104
+ stage="provider",
105
+ event="provider.recovery.tool_salvaged",
106
+ source="provider",
107
+ provider=self._provider_name,
108
+ request_id=request_id,
109
+ )
110
+ return events
111
+
112
+ partial_text = sse.accumulated_text
113
+ partial_thinking = sse.accumulated_reasoning
114
+ if not partial_text and not partial_thinking:
115
+ return None
116
+
117
+ recovery_body = make_openai_text_recovery_body(body, partial_text)
118
+ text, thinking = await self.collect_text(recovery_body)
119
+ text_suffix = continuation_suffix(partial_text, text)
120
+ thinking_suffix = continuation_suffix(partial_thinking, thinking)
121
+ events: list[str] = []
122
+ if thinking_suffix:
123
+ for event in sse.ensure_thinking_block():
124
+ events.append(event)
125
+ events.append(sse.emit_thinking_delta(thinking_suffix))
126
+ if text_suffix:
127
+ for event in sse.ensure_text_block():
128
+ events.append(event)
129
+ events.append(sse.emit_text_delta(text_suffix))
130
+ if not events:
131
+ return None
132
+ events.extend(sse.close_all_blocks())
133
+ events.append(sse.message_delta("end_turn", sse.estimate_output_tokens()))
134
+ events.append(sse.message_stop())
135
+ trace_event(
136
+ stage="provider",
137
+ event="provider.recovery.continued",
138
+ source="provider",
139
+ provider=self._provider_name,
140
+ request_id=request_id,
141
+ )
142
+ return events
143
+
144
+ def emit_error_tail(self, sse: SSEBuilder, error_message: str) -> Iterator[str]:
145
+ """Emit the canonical OpenAI-chat final error tail."""
146
+ yield from sse.close_all_blocks()
147
+ if sse.blocks.has_emitted_tool_block():
148
+ yield sse.emit_top_level_error(error_message)
149
+ else:
150
+ yield from sse.emit_error(error_message)
151
+ yield sse.message_delta("end_turn", 1)
152
+ yield sse.message_stop()
153
+
154
+ async def _repair_tool_args(
155
+ self,
156
+ *,
157
+ body: dict[str, Any],
158
+ sse: SSEBuilder,
159
+ request: Any,
160
+ tool_argument_alias_buffers: dict[int, str],
161
+ ) -> list[str] | None:
162
+ schemas = tool_schemas_by_name(request)
163
+ events: list[str] = []
164
+ for tool_index, state in started_tool_states(sse):
165
+ emitted_prefix = "".join(state.contents)
166
+ repair_prefix = emitted_prefix
167
+ if not repair_prefix and state.name == "Task" and state.task_arg_buffer:
168
+ repair_prefix = state.task_arg_buffer
169
+ if not repair_prefix and tool_index in tool_argument_alias_buffers:
170
+ repair_prefix = tool_argument_alias_buffers[tool_index]
171
+ if (
172
+ parse_complete_tool_input(repair_prefix, state.name, schemas)
173
+ is not None
174
+ ):
175
+ if not emitted_prefix:
176
+ yield_text = repair_prefix
177
+ if yield_text:
178
+ events.append(sse.emit_tool_delta(tool_index, yield_text))
179
+ continue
180
+
181
+ schema = schemas.get(state.name)
182
+ recovery_body = make_openai_tool_repair_body(
183
+ body,
184
+ tool_name=state.name,
185
+ prefix=repair_prefix,
186
+ input_schema=schema.input_schema if schema is not None else None,
187
+ )
188
+ accepted_suffix: str | None = None
189
+ for attempt in range(MIDSTREAM_RECOVERY_ATTEMPTS):
190
+ text, _ = await self.collect_text(recovery_body)
191
+ repair = accept_tool_json_repair(
192
+ repair_prefix,
193
+ text,
194
+ tool_name=state.name,
195
+ schemas=schemas,
196
+ )
197
+ if repair is not None:
198
+ accepted_suffix = repair.suffix
199
+ trace_event(
200
+ stage="provider",
201
+ event="provider.recovery.tool_repaired",
202
+ source="provider",
203
+ provider=self._provider_name,
204
+ tool_name=state.name,
205
+ attempt=attempt + 1,
206
+ )
207
+ break
208
+ if accepted_suffix is None:
209
+ return None
210
+ to_emit = (
211
+ accepted_suffix if emitted_prefix else repair_prefix + accepted_suffix
212
+ )
213
+ if to_emit:
214
+ events.append(sse.emit_tool_delta(tool_index, to_emit))
215
+ if not all_started_tools_complete(sse, request):
216
+ return None
217
+ return events