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,206 @@
1
+ """Native Anthropic Messages recovery event construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Callable
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from core.anthropic.emitted_sse_tracker import EmittedNativeSseTracker
11
+ from core.anthropic.stream_contracts import parse_sse_text
12
+ from core.anthropic.stream_recovery import (
13
+ MIDSTREAM_RECOVERY_ATTEMPTS,
14
+ accept_tool_json_repair,
15
+ continuation_suffix,
16
+ is_retryable_stream_error,
17
+ make_native_text_recovery_body,
18
+ make_native_tool_repair_body,
19
+ parse_complete_tool_input,
20
+ tool_schemas_by_name,
21
+ )
22
+ from core.trace import trace_event
23
+
24
+ from .http import maybe_await_aclose
25
+
26
+ IterStreamChunks = Callable[..., AsyncIterator[str]]
27
+
28
+
29
+ class AnthropicMessagesRecovery:
30
+ """Construct recovery events for interrupted native Anthropic streams."""
31
+
32
+ def __init__(
33
+ self,
34
+ transport: Any,
35
+ *,
36
+ iter_stream_chunks: IterStreamChunks,
37
+ ) -> None:
38
+ self._transport = transport
39
+ self._iter_stream_chunks = iter_stream_chunks
40
+
41
+ async def collect_text(
42
+ self,
43
+ body: dict[str, Any],
44
+ *,
45
+ req_tag: str,
46
+ thinking_enabled: bool,
47
+ ) -> tuple[str, str]:
48
+ """Collect text/thinking from an internal native recovery request."""
49
+ last_error: Exception | None = None
50
+ for attempt in range(MIDSTREAM_RECOVERY_ATTEMPTS):
51
+ response: httpx.Response | None = None
52
+ try:
53
+ response = (
54
+ await self._transport._global_rate_limiter.execute_with_retry(
55
+ self._transport._validated_stream_send, body, req_tag=req_tag
56
+ )
57
+ )
58
+ state = self._transport._new_stream_state(
59
+ None, thinking_enabled=thinking_enabled
60
+ )
61
+ chunks = [
62
+ chunk
63
+ async for chunk in self._iter_stream_chunks(
64
+ response,
65
+ state=state,
66
+ thinking_enabled=thinking_enabled,
67
+ )
68
+ ]
69
+ text_parts: list[str] = []
70
+ thinking_parts: list[str] = []
71
+ for event in parse_sse_text("".join(chunks)):
72
+ delta = event.data.get("delta")
73
+ if not isinstance(delta, dict):
74
+ continue
75
+ text = delta.get("text")
76
+ if isinstance(text, str):
77
+ text_parts.append(text)
78
+ thinking = delta.get("thinking")
79
+ if isinstance(thinking, str):
80
+ thinking_parts.append(thinking)
81
+ return "".join(text_parts), "".join(thinking_parts)
82
+ except Exception as error:
83
+ last_error = error
84
+ if not is_retryable_stream_error(error):
85
+ raise
86
+ trace_event(
87
+ stage="provider",
88
+ event="provider.recovery.retry",
89
+ source="provider",
90
+ provider=self._transport._provider_name,
91
+ recovery_kind="native_text",
92
+ attempt=attempt + 1,
93
+ max_attempts=MIDSTREAM_RECOVERY_ATTEMPTS,
94
+ exc_type=type(error).__name__,
95
+ )
96
+ finally:
97
+ if response is not None and not response.is_closed:
98
+ await maybe_await_aclose(response)
99
+ if last_error is not None:
100
+ raise last_error
101
+ return "", ""
102
+
103
+ async def events(
104
+ self,
105
+ *,
106
+ body: dict[str, Any],
107
+ request: Any,
108
+ tracker: EmittedNativeSseTracker,
109
+ error: Exception,
110
+ request_id: str | None,
111
+ req_tag: str,
112
+ thinking_enabled: bool,
113
+ ) -> list[str] | None:
114
+ """Build recovery events, or return None when recovery is impossible."""
115
+ if not is_retryable_stream_error(error):
116
+ return None
117
+
118
+ schemas = tool_schemas_by_name(request)
119
+ if tracker.has_tool_block():
120
+ repair_events: list[str] = []
121
+ for index, block in enumerate(tracker.tool_blocks()):
122
+ if (
123
+ block.tool_id
124
+ and block.name
125
+ and parse_complete_tool_input(block.content, block.name, schemas)
126
+ is not None
127
+ ):
128
+ continue
129
+ schema = schemas.get(block.name)
130
+ recovery_body = make_native_tool_repair_body(
131
+ body,
132
+ tool_name=block.name,
133
+ prefix=block.content,
134
+ input_schema=schema.input_schema if schema is not None else None,
135
+ )
136
+ accepted_suffix: str | None = None
137
+ for attempt in range(MIDSTREAM_RECOVERY_ATTEMPTS):
138
+ text, _ = await self.collect_text(
139
+ recovery_body,
140
+ req_tag=req_tag,
141
+ thinking_enabled=thinking_enabled,
142
+ )
143
+ repair = accept_tool_json_repair(
144
+ block.content,
145
+ text,
146
+ tool_name=block.name,
147
+ schemas=schemas,
148
+ )
149
+ if repair is not None:
150
+ accepted_suffix = repair.suffix
151
+ trace_event(
152
+ stage="provider",
153
+ event="provider.recovery.tool_repaired",
154
+ source="provider",
155
+ provider=self._transport._provider_name,
156
+ tool_name=block.name,
157
+ attempt=attempt + 1,
158
+ )
159
+ break
160
+ if accepted_suffix is None:
161
+ return None
162
+ repair_events.extend(
163
+ tracker.append_tool_repair_suffix(index, accepted_suffix)
164
+ )
165
+
166
+ if not tracker.can_salvage_tool_use(schemas):
167
+ return None
168
+ events = list(repair_events)
169
+ events.extend(tracker.iter_success_tail("tool_use"))
170
+ trace_event(
171
+ stage="provider",
172
+ event="provider.recovery.tool_salvaged",
173
+ source="provider",
174
+ provider=self._transport._provider_name,
175
+ request_id=request_id,
176
+ )
177
+ return events
178
+
179
+ partial_text = tracker.emitted_text()
180
+ partial_thinking = tracker.emitted_thinking()
181
+ if not partial_text and not partial_thinking:
182
+ return None
183
+ recovery_body = make_native_text_recovery_body(body, partial_text)
184
+ text, thinking = await self.collect_text(
185
+ recovery_body,
186
+ req_tag=req_tag,
187
+ thinking_enabled=thinking_enabled,
188
+ )
189
+ text_suffix = continuation_suffix(partial_text, text)
190
+ thinking_suffix = continuation_suffix(partial_thinking, thinking)
191
+ events: list[str] = []
192
+ if thinking_suffix:
193
+ events.extend(tracker.append_thinking_suffix(thinking_suffix))
194
+ if text_suffix:
195
+ events.extend(tracker.append_text_suffix(text_suffix))
196
+ if not events:
197
+ return None
198
+ events.extend(tracker.iter_success_tail("end_turn"))
199
+ trace_event(
200
+ stage="provider",
201
+ event="provider.recovery.continued",
202
+ source="provider",
203
+ provider=self._transport._provider_name,
204
+ request_id=request_id,
205
+ )
206
+ return events
@@ -0,0 +1,295 @@
1
+ """Per-request native Anthropic Messages stream runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from core.anthropic.emitted_sse_tracker import EmittedNativeSseTracker
11
+ from core.anthropic.native_sse_block_policy import NativeSseBlockPolicyState
12
+ from core.anthropic.stream_recovery import (
13
+ TruncatedProviderStreamError,
14
+ tool_schemas_by_name,
15
+ )
16
+ from core.anthropic.stream_recovery_session import (
17
+ StreamFailureAction,
18
+ StreamRecoverySession,
19
+ )
20
+ from core.trace import provider_native_messages_body_snapshot, trace_event
21
+
22
+ from .http import maybe_await_aclose
23
+ from .recovery import AnthropicMessagesRecovery
24
+
25
+
26
+ async def iter_sse_lines(response: httpx.Response) -> AsyncIterator[str]:
27
+ """Yield raw SSE line chunks preserving local provider behavior."""
28
+ async for line in response.aiter_lines():
29
+ if line:
30
+ yield f"{line}\n"
31
+ else:
32
+ yield "\n"
33
+
34
+
35
+ async def iter_sse_events(response: httpx.Response) -> AsyncIterator[str]:
36
+ """Group line-delimited SSE responses into full SSE events."""
37
+ event_lines: list[str] = []
38
+ async for line in response.aiter_lines():
39
+ if line:
40
+ event_lines.append(line)
41
+ continue
42
+ if event_lines:
43
+ yield "\n".join(event_lines) + "\n\n"
44
+ event_lines.clear()
45
+ if event_lines:
46
+ yield "\n".join(event_lines) + "\n\n"
47
+
48
+
49
+ class AnthropicMessagesStreamRunner:
50
+ """Own mutable state for one native Anthropic provider stream."""
51
+
52
+ def __init__(
53
+ self,
54
+ transport: Any,
55
+ *,
56
+ request: Any,
57
+ input_tokens: int,
58
+ request_id: str | None,
59
+ thinking_enabled: bool | None,
60
+ ) -> None:
61
+ self._transport = transport
62
+ self._request = request
63
+ self._input_tokens = input_tokens
64
+ self._request_id = request_id
65
+ self._thinking_enabled = thinking_enabled
66
+ self._recovery = AnthropicMessagesRecovery(
67
+ transport,
68
+ iter_stream_chunks=self.iter_stream_chunks,
69
+ )
70
+
71
+ async def run(self) -> AsyncIterator[str]:
72
+ """Stream response via a native Anthropic-compatible messages endpoint."""
73
+ tag = self._transport._provider_name
74
+ req_tag = f" request_id={self._request_id}" if self._request_id else ""
75
+ body = self._transport._build_request_body(
76
+ self._request, thinking_enabled=self._thinking_enabled
77
+ )
78
+ thinking_enabled = self._transport._is_thinking_enabled(
79
+ self._request, self._thinking_enabled
80
+ )
81
+
82
+ trace_event(
83
+ stage="provider",
84
+ event="provider.request.sent",
85
+ source="provider",
86
+ provider=tag,
87
+ gateway_model=self._request.model,
88
+ downstream_model=body.get("model"),
89
+ message_count=len(body.get("messages", [])),
90
+ tool_count=len(body.get("tools", [])),
91
+ body=provider_native_messages_body_snapshot(body),
92
+ )
93
+
94
+ response: httpx.Response | None = None
95
+ sent_any_event = False
96
+ state = self._transport._new_stream_state(
97
+ self._request, thinking_enabled=thinking_enabled
98
+ )
99
+ emitted_tracker = EmittedNativeSseTracker()
100
+ recovery_session = StreamRecoverySession(
101
+ provider_name=tag,
102
+ request_id=self._request_id,
103
+ )
104
+
105
+ async with self._transport._global_rate_limiter.concurrency_slot():
106
+ while True:
107
+ stream_opened = False
108
+ try:
109
+ response = (
110
+ await self._transport._global_rate_limiter.execute_with_retry(
111
+ self._transport._validated_stream_send,
112
+ body,
113
+ req_tag=req_tag,
114
+ )
115
+ )
116
+ stream_opened = True
117
+
118
+ chunk_count = 0
119
+ chunk_bytes = 0
120
+
121
+ async for chunk in self.iter_stream_chunks(
122
+ response,
123
+ state=state,
124
+ thinking_enabled=thinking_enabled,
125
+ ):
126
+ chunk_count += 1
127
+ chunk_bytes += len(chunk.encode("utf-8", errors="replace"))
128
+ emitted_tracker.feed(chunk)
129
+ for event in recovery_session.push(chunk):
130
+ sent_any_event = True
131
+ yield event
132
+
133
+ if not emitted_tracker.has_terminal_message():
134
+ raise TruncatedProviderStreamError(
135
+ "Provider stream ended without message_stop."
136
+ )
137
+
138
+ trace_event(
139
+ stage="provider",
140
+ event="provider.response.completed",
141
+ source="provider",
142
+ provider=tag,
143
+ gateway_model=self._request.model,
144
+ sse_chunks_out=chunk_count,
145
+ sse_bytes_out=chunk_bytes,
146
+ )
147
+ for event in recovery_session.flush():
148
+ sent_any_event = True
149
+ yield event
150
+ return
151
+
152
+ except Exception as error:
153
+ generated_output = emitted_tracker.has_content_block()
154
+ complete_tool_salvageable = (
155
+ generated_output
156
+ and emitted_tracker.can_salvage_tool_use(
157
+ tool_schemas_by_name(self._request)
158
+ )
159
+ )
160
+ decision = recovery_session.advance_failure(
161
+ error,
162
+ stream_opened=stream_opened,
163
+ generated_output=generated_output,
164
+ complete_tool_salvageable=complete_tool_salvageable,
165
+ )
166
+ if decision.action == StreamFailureAction.EARLY_RETRY:
167
+ if response is not None and not response.is_closed:
168
+ await maybe_await_aclose(response)
169
+ response = None
170
+ state = self._transport._new_stream_state(
171
+ self._request, thinking_enabled=thinking_enabled
172
+ )
173
+ emitted_tracker = EmittedNativeSseTracker()
174
+ sent_any_event = False
175
+ continue
176
+
177
+ if decision.action == StreamFailureAction.MIDSTREAM_RECOVERY:
178
+ try:
179
+ recovery_events = await self._recovery.events(
180
+ body=body,
181
+ request=self._request,
182
+ tracker=emitted_tracker,
183
+ error=error,
184
+ request_id=self._request_id,
185
+ req_tag=req_tag,
186
+ thinking_enabled=thinking_enabled,
187
+ )
188
+ except Exception as recovery_error:
189
+ trace_event(
190
+ stage="provider",
191
+ event="provider.recovery.failed",
192
+ source="provider",
193
+ provider=tag,
194
+ request_id=self._request_id,
195
+ exc_type=type(recovery_error).__name__,
196
+ )
197
+ recovery_events = None
198
+ if recovery_events is not None:
199
+ for event in recovery_session.flush_uncommitted(decision):
200
+ sent_any_event = True
201
+ yield event
202
+ for event in recovery_events:
203
+ yield event
204
+ return
205
+
206
+ if not isinstance(error, httpx.HTTPStatusError):
207
+ self._transport._log_stream_transport_error(
208
+ tag, req_tag, error, request_id=self._request_id
209
+ )
210
+ error_message = self._transport._get_error_message(
211
+ error, self._request_id
212
+ )
213
+
214
+ if response is not None and not response.is_closed:
215
+ await maybe_await_aclose(response)
216
+
217
+ trace_event(
218
+ stage="provider",
219
+ event="provider.response.error",
220
+ source="provider",
221
+ provider=tag,
222
+ error_message=error_message,
223
+ exc_type=type(error).__name__,
224
+ mid_stream=(
225
+ sent_any_event
226
+ or decision.committed
227
+ or decision.has_buffered
228
+ ),
229
+ )
230
+ if decision.committed or decision.has_buffered:
231
+ if not decision.committed:
232
+ for event in recovery_session.flush():
233
+ sent_any_event = True
234
+ yield event
235
+ for event in emitted_tracker.iter_close_unclosed_blocks():
236
+ yield event
237
+ for event in emitted_tracker.iter_midstream_error_tail(
238
+ error_message,
239
+ request=self._request,
240
+ input_tokens=self._input_tokens,
241
+ log_raw_sse_events=(
242
+ self._transport._config.log_raw_sse_events
243
+ ),
244
+ ):
245
+ yield event
246
+ else:
247
+ recovery_session.discard()
248
+ for event in self._transport._emit_error_events(
249
+ request=self._request,
250
+ input_tokens=self._input_tokens,
251
+ error_message=error_message,
252
+ sent_any_event=False,
253
+ ):
254
+ yield event
255
+ return
256
+ finally:
257
+ if response is not None and not response.is_closed:
258
+ await maybe_await_aclose(response)
259
+
260
+ async def iter_stream_chunks(
261
+ self,
262
+ response: httpx.Response,
263
+ *,
264
+ state: Any,
265
+ thinking_enabled: bool,
266
+ ) -> AsyncIterator[str]:
267
+ """Yield chunks according to the provider's observable stream shape."""
268
+ if self._transport.stream_chunk_mode == "line" and isinstance(
269
+ state, NativeSseBlockPolicyState
270
+ ):
271
+ async for event in iter_sse_events(response):
272
+ output_event = self._transport._transform_stream_event(
273
+ event,
274
+ state,
275
+ thinking_enabled=thinking_enabled,
276
+ )
277
+ if output_event is None:
278
+ continue
279
+ for line in output_event.splitlines(keepends=True):
280
+ yield line
281
+ return
282
+
283
+ if self._transport.stream_chunk_mode == "line":
284
+ async for chunk in iter_sse_lines(response):
285
+ yield chunk
286
+ return
287
+
288
+ async for event in iter_sse_events(response):
289
+ output_event = self._transport._transform_stream_event(
290
+ event,
291
+ state,
292
+ thinking_enabled=thinking_enabled,
293
+ )
294
+ if output_event is not None:
295
+ yield output_event