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
core/anthropic/sse.py ADDED
@@ -0,0 +1,440 @@
1
+ """SSE event builder for Anthropic-format streaming responses."""
2
+
3
+ import hashlib
4
+ import json
5
+ from collections.abc import Iterator
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from loguru import logger
10
+
11
+ try:
12
+ import tiktoken
13
+
14
+ ENCODER = tiktoken.get_encoding("cl100k_base")
15
+ except Exception:
16
+ ENCODER = None
17
+
18
+
19
+ # Standard headers for Anthropic-style ``text/event-stream`` responses from this proxy.
20
+ ANTHROPIC_SSE_RESPONSE_HEADERS: dict[str, str] = {
21
+ "X-Accel-Buffering": "no",
22
+ "Cache-Control": "no-cache",
23
+ "Connection": "keep-alive",
24
+ }
25
+
26
+ STOP_REASON_MAP = {
27
+ "stop": "end_turn",
28
+ "length": "max_tokens",
29
+ "tool_calls": "tool_use",
30
+ "content_filter": "end_turn",
31
+ }
32
+
33
+
34
+ def map_stop_reason(openai_reason: str | None) -> str:
35
+ """Map OpenAI finish_reason to Anthropic stop_reason."""
36
+ return (
37
+ STOP_REASON_MAP.get(openai_reason, "end_turn") if openai_reason else "end_turn"
38
+ )
39
+
40
+
41
+ def _safe_usage_int(value: object) -> int:
42
+ """Coerce streamed usage counters to int; non-integers become 0."""
43
+ return value if isinstance(value, int) else 0
44
+
45
+
46
+ def format_sse_event(event_type: str, data: dict) -> str:
47
+ """Format one Anthropic-style SSE event (no logging)."""
48
+ return f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
49
+
50
+
51
+ @dataclass
52
+ class ToolCallState:
53
+ """State for a single streaming tool call."""
54
+
55
+ block_index: int
56
+ tool_id: str
57
+ name: str
58
+ extra_content: dict[str, Any] | None = None
59
+ contents: list[str] = field(default_factory=list)
60
+ started: bool = False
61
+ task_arg_buffer: str = ""
62
+ task_args_emitted: bool = False
63
+ pre_start_args: str = ""
64
+
65
+
66
+ @dataclass
67
+ class ContentBlockManager:
68
+ """Manage content block indices and state."""
69
+
70
+ next_index: int = 0
71
+ thinking_index: int = -1
72
+ text_index: int = -1
73
+ thinking_started: bool = False
74
+ text_started: bool = False
75
+ tool_states: dict[int, ToolCallState] = field(default_factory=dict)
76
+
77
+ def allocate_index(self) -> int:
78
+ idx = self.next_index
79
+ self.next_index += 1
80
+ return idx
81
+
82
+ def ensure_tool_state(self, index: int) -> ToolCallState:
83
+ """Create tool stream state for ``index`` when the first tool delta arrives."""
84
+ if index not in self.tool_states:
85
+ self.tool_states[index] = ToolCallState(block_index=-1, tool_id="", name="")
86
+ return self.tool_states[index]
87
+
88
+ def set_stream_tool_id(self, index: int, tool_id: str | None) -> None:
89
+ """Record OpenAI tool call id before ``content_block_start`` (split-stream providers)."""
90
+ if not tool_id:
91
+ return
92
+ state = self.ensure_tool_state(index)
93
+ state.tool_id = str(tool_id)
94
+
95
+ def set_tool_extra_content(
96
+ self, index: int, extra_content: dict[str, Any] | None
97
+ ) -> None:
98
+ """Record provider-specific OpenAI tool-call metadata before block start."""
99
+ if not extra_content:
100
+ return
101
+ state = self.ensure_tool_state(index)
102
+ state.extra_content = extra_content
103
+
104
+ def register_tool_name(self, index: int, name: str) -> None:
105
+ """Record tool name fragments as they arrive from chunked OpenAI streams.
106
+
107
+ Names may be split across deltas; later chunks can extend (``ab`` + ``c``)
108
+ or repeat prefixes, so we merge conservatively.
109
+ """
110
+ if index not in self.tool_states:
111
+ self.tool_states[index] = ToolCallState(
112
+ block_index=-1, tool_id="", name=name
113
+ )
114
+ return
115
+ state = self.tool_states[index]
116
+ prev = state.name
117
+ if not prev or name.startswith(prev):
118
+ state.name = name
119
+ elif not prev.startswith(name):
120
+ state.name = prev + name
121
+
122
+ def buffer_task_args(self, index: int, args: str) -> dict | None:
123
+ state = self.tool_states.get(index)
124
+ if state is None or state.task_args_emitted:
125
+ return None
126
+
127
+ state.task_arg_buffer += args
128
+ try:
129
+ args_json = json.loads(state.task_arg_buffer)
130
+ except Exception:
131
+ return None
132
+
133
+ _normalize_task_run_in_background(args_json)
134
+
135
+ state.task_args_emitted = True
136
+ state.task_arg_buffer = ""
137
+ return args_json
138
+
139
+ def has_emitted_tool_block(self) -> bool:
140
+ """True when native OpenAI tool streaming has started a ``tool_use`` block."""
141
+ return any(s.started for s in self.tool_states.values())
142
+
143
+ def flush_task_arg_buffers(self) -> list[tuple[int, str]]:
144
+ results: list[tuple[int, str]] = []
145
+ for tool_index, state in list(self.tool_states.items()):
146
+ if not state.task_arg_buffer or state.task_args_emitted:
147
+ continue
148
+
149
+ out = "{}"
150
+ try:
151
+ args_json = json.loads(state.task_arg_buffer)
152
+ _normalize_task_run_in_background(args_json)
153
+ out = json.dumps(args_json)
154
+ except (json.JSONDecodeError, TypeError, ValueError) as e:
155
+ digest = hashlib.sha256(
156
+ state.task_arg_buffer.encode("utf-8", errors="replace")
157
+ ).hexdigest()[:16]
158
+ logger.warning(
159
+ "Task args invalid JSON (id={} len={} buffer_sha256_prefix={}): {}",
160
+ state.tool_id or "unknown",
161
+ len(state.task_arg_buffer),
162
+ digest,
163
+ e,
164
+ )
165
+
166
+ state.task_args_emitted = True
167
+ state.task_arg_buffer = ""
168
+ results.append((tool_index, out))
169
+ return results
170
+
171
+
172
+ def _normalize_task_run_in_background(args_json: dict) -> None:
173
+ """Force Claude Code Task subagents to run in foreground (single shared rule)."""
174
+ if args_json.get("run_in_background") is not False:
175
+ args_json["run_in_background"] = False
176
+
177
+
178
+ class SSEBuilder:
179
+ """Builder for Anthropic SSE streaming events."""
180
+
181
+ def __init__(
182
+ self,
183
+ message_id: str,
184
+ model: str,
185
+ input_tokens: int = 0,
186
+ *,
187
+ log_raw_events: bool = False,
188
+ ):
189
+ self.message_id = message_id
190
+ self.model = model
191
+ self.input_tokens = input_tokens
192
+ self._log_raw_events = log_raw_events
193
+ self.blocks = ContentBlockManager()
194
+ self._accumulated_text_parts: list[str] = []
195
+ self._accumulated_reasoning_parts: list[str] = []
196
+
197
+ def _format_event(self, event_type: str, data: dict) -> str:
198
+ event_str = format_sse_event(event_type, data)
199
+ if self._log_raw_events:
200
+ logger.debug("SSE_EVENT: {} - {}", event_type, event_str.strip())
201
+ return event_str
202
+
203
+ def message_start(self) -> str:
204
+ safe_input = _safe_usage_int(self.input_tokens)
205
+ usage = {"input_tokens": safe_input, "output_tokens": 1}
206
+ return self._format_event(
207
+ "message_start",
208
+ {
209
+ "type": "message_start",
210
+ "message": {
211
+ "id": self.message_id,
212
+ "type": "message",
213
+ "role": "assistant",
214
+ "content": [],
215
+ "model": self.model,
216
+ "stop_reason": None,
217
+ "stop_sequence": None,
218
+ "usage": usage,
219
+ },
220
+ },
221
+ )
222
+
223
+ def message_delta(self, stop_reason: str, output_tokens: int | None) -> str:
224
+ safe_in = _safe_usage_int(self.input_tokens)
225
+ safe_out = output_tokens if isinstance(output_tokens, int) else 0
226
+ return self._format_event(
227
+ "message_delta",
228
+ {
229
+ "type": "message_delta",
230
+ "delta": {"stop_reason": stop_reason, "stop_sequence": None},
231
+ "usage": {
232
+ "input_tokens": safe_in,
233
+ "output_tokens": safe_out,
234
+ },
235
+ },
236
+ )
237
+
238
+ def message_stop(self) -> str:
239
+ return self._format_event("message_stop", {"type": "message_stop"})
240
+
241
+ def content_block_start(self, index: int, block_type: str, **kwargs) -> str:
242
+ content_block: dict = {"type": block_type}
243
+ if block_type == "thinking":
244
+ content_block["thinking"] = kwargs.get("thinking", "")
245
+ elif block_type == "text":
246
+ content_block["text"] = kwargs.get("text", "")
247
+ elif block_type == "tool_use":
248
+ content_block["id"] = kwargs.get("id", "")
249
+ content_block["name"] = kwargs.get("name", "")
250
+ content_block["input"] = kwargs.get("input", {})
251
+ extra_content = kwargs.get("extra_content")
252
+ if isinstance(extra_content, dict) and extra_content:
253
+ content_block["extra_content"] = extra_content
254
+
255
+ return self._format_event(
256
+ "content_block_start",
257
+ {
258
+ "type": "content_block_start",
259
+ "index": index,
260
+ "content_block": content_block,
261
+ },
262
+ )
263
+
264
+ def content_block_delta(self, index: int, delta_type: str, content: str) -> str:
265
+ delta: dict = {"type": delta_type}
266
+ if delta_type == "thinking_delta":
267
+ delta["thinking"] = content
268
+ elif delta_type == "text_delta":
269
+ delta["text"] = content
270
+ elif delta_type == "input_json_delta":
271
+ delta["partial_json"] = content
272
+
273
+ return self._format_event(
274
+ "content_block_delta",
275
+ {
276
+ "type": "content_block_delta",
277
+ "index": index,
278
+ "delta": delta,
279
+ },
280
+ )
281
+
282
+ def content_block_stop(self, index: int) -> str:
283
+ return self._format_event(
284
+ "content_block_stop",
285
+ {
286
+ "type": "content_block_stop",
287
+ "index": index,
288
+ },
289
+ )
290
+
291
+ def start_thinking_block(self) -> str:
292
+ self.blocks.thinking_index = self.blocks.allocate_index()
293
+ self.blocks.thinking_started = True
294
+ return self.content_block_start(self.blocks.thinking_index, "thinking")
295
+
296
+ def emit_thinking_delta(self, content: str) -> str:
297
+ self._accumulated_reasoning_parts.append(content)
298
+ return self.content_block_delta(
299
+ self.blocks.thinking_index, "thinking_delta", content
300
+ )
301
+
302
+ def stop_thinking_block(self) -> str:
303
+ self.blocks.thinking_started = False
304
+ return self.content_block_stop(self.blocks.thinking_index)
305
+
306
+ def start_text_block(self) -> str:
307
+ self.blocks.text_index = self.blocks.allocate_index()
308
+ self.blocks.text_started = True
309
+ return self.content_block_start(self.blocks.text_index, "text")
310
+
311
+ def emit_text_delta(self, content: str) -> str:
312
+ self._accumulated_text_parts.append(content)
313
+ return self.content_block_delta(self.blocks.text_index, "text_delta", content)
314
+
315
+ def stop_text_block(self) -> str:
316
+ self.blocks.text_started = False
317
+ return self.content_block_stop(self.blocks.text_index)
318
+
319
+ def start_tool_block(
320
+ self,
321
+ tool_index: int,
322
+ tool_id: str,
323
+ name: str,
324
+ *,
325
+ extra_content: dict[str, Any] | None = None,
326
+ ) -> str:
327
+ block_idx = self.blocks.allocate_index()
328
+ if tool_index in self.blocks.tool_states:
329
+ state = self.blocks.tool_states[tool_index]
330
+ state.block_index = block_idx
331
+ state.tool_id = tool_id
332
+ if extra_content:
333
+ state.extra_content = extra_content
334
+ state.started = True
335
+ else:
336
+ self.blocks.tool_states[tool_index] = ToolCallState(
337
+ block_index=block_idx,
338
+ tool_id=tool_id,
339
+ name=name,
340
+ extra_content=extra_content,
341
+ started=True,
342
+ )
343
+ return self.content_block_start(
344
+ block_idx,
345
+ "tool_use",
346
+ id=tool_id,
347
+ name=name,
348
+ extra_content=extra_content,
349
+ )
350
+
351
+ def emit_tool_delta(self, tool_index: int, partial_json: str) -> str:
352
+ state = self.blocks.tool_states[tool_index]
353
+ state.contents.append(partial_json)
354
+ return self.content_block_delta(
355
+ state.block_index, "input_json_delta", partial_json
356
+ )
357
+
358
+ def stop_tool_block(self, tool_index: int) -> str:
359
+ block_idx = self.blocks.tool_states[tool_index].block_index
360
+ return self.content_block_stop(block_idx)
361
+
362
+ def ensure_thinking_block(self) -> Iterator[str]:
363
+ if self.blocks.text_started:
364
+ yield self.stop_text_block()
365
+ if not self.blocks.thinking_started:
366
+ yield self.start_thinking_block()
367
+
368
+ def ensure_text_block(self) -> Iterator[str]:
369
+ if self.blocks.thinking_started:
370
+ yield self.stop_thinking_block()
371
+ if not self.blocks.text_started:
372
+ yield self.start_text_block()
373
+
374
+ def close_content_blocks(self) -> Iterator[str]:
375
+ if self.blocks.thinking_started:
376
+ yield self.stop_thinking_block()
377
+ if self.blocks.text_started:
378
+ yield self.stop_text_block()
379
+
380
+ def close_all_blocks(self) -> Iterator[str]:
381
+ yield from self.close_content_blocks()
382
+ for tool_index, state in list(self.blocks.tool_states.items()):
383
+ if state.started:
384
+ yield self.stop_tool_block(tool_index)
385
+
386
+ def emit_error(self, error_message: str) -> Iterator[str]:
387
+ error_index = self.blocks.allocate_index()
388
+ yield self.content_block_start(error_index, "text")
389
+ yield self.content_block_delta(error_index, "text_delta", error_message)
390
+ yield self.content_block_stop(error_index)
391
+
392
+ def emit_top_level_error(self, error_message: str) -> str:
393
+ """Emit a top-level ``event: error`` (not assistant text) for transport failures."""
394
+ return self._format_event(
395
+ "error",
396
+ {
397
+ "type": "error",
398
+ "error": {
399
+ "type": "api_error",
400
+ "message": error_message,
401
+ },
402
+ },
403
+ )
404
+
405
+ @property
406
+ def accumulated_text(self) -> str:
407
+ return "".join(self._accumulated_text_parts)
408
+
409
+ @property
410
+ def accumulated_reasoning(self) -> str:
411
+ return "".join(self._accumulated_reasoning_parts)
412
+
413
+ def estimate_output_tokens(self) -> int:
414
+ accumulated_text = self.accumulated_text
415
+ accumulated_reasoning = self.accumulated_reasoning
416
+ if ENCODER:
417
+ text_tokens = len(ENCODER.encode(accumulated_text))
418
+ reasoning_tokens = len(ENCODER.encode(accumulated_reasoning))
419
+ tool_tokens = 0
420
+ started_tool_count = 0
421
+ for state in self.blocks.tool_states.values():
422
+ tool_tokens += len(ENCODER.encode(state.name))
423
+ tool_tokens += len(ENCODER.encode("".join(state.contents)))
424
+ tool_tokens += 15
425
+ if state.started:
426
+ started_tool_count += 1
427
+
428
+ block_count = (
429
+ (1 if accumulated_reasoning else 0)
430
+ + (1 if accumulated_text else 0)
431
+ + started_tool_count
432
+ )
433
+ return text_tokens + reasoning_tokens + tool_tokens + (block_count * 4)
434
+
435
+ text_tokens = len(accumulated_text) // 4
436
+ reasoning_tokens = len(accumulated_reasoning) // 4
437
+ tool_tokens = (
438
+ sum(1 for state in self.blocks.tool_states.values() if state.started) * 50
439
+ )
440
+ return text_tokens + reasoning_tokens + tool_tokens
@@ -0,0 +1,205 @@
1
+ """Neutral SSE parsing and Anthropic stream shape assertions.
2
+
3
+ Used by default CI contract tests and by opt-in live smoke scenarios.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from collections.abc import Iterable
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from .server_tool_sse import (
14
+ SERVER_TOOL_USE,
15
+ WEB_FETCH_TOOL_RESULT,
16
+ WEB_SEARCH_TOOL_RESULT,
17
+ )
18
+
19
+ # Content blocks that only use content_block_start/stop (no deltas), including
20
+ # Anthropic server tools and eager text emitted in a single start event.
21
+ _NO_DELTA_BLOCK_KINDS = frozenset(
22
+ {
23
+ SERVER_TOOL_USE,
24
+ WEB_SEARCH_TOOL_RESULT,
25
+ WEB_FETCH_TOOL_RESULT,
26
+ "text_eager",
27
+ "redacted_thinking",
28
+ }
29
+ )
30
+
31
+ _ALLOWED_BLOCK_START_TYPES = frozenset(
32
+ {
33
+ "text",
34
+ "thinking",
35
+ "tool_use",
36
+ "redacted_thinking",
37
+ SERVER_TOOL_USE,
38
+ WEB_SEARCH_TOOL_RESULT,
39
+ WEB_FETCH_TOOL_RESULT,
40
+ }
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class SSEEvent:
46
+ event: str
47
+ data: dict[str, Any]
48
+ raw: str
49
+
50
+
51
+ def parse_sse_lines(lines: Iterable[str]) -> list[SSEEvent]:
52
+ events: list[SSEEvent] = []
53
+ current_event = ""
54
+ data_parts: list[str] = []
55
+ raw_parts: list[str] = []
56
+
57
+ for line in lines:
58
+ stripped = line.rstrip("\r\n")
59
+ if stripped == "":
60
+ _append_event(events, current_event, data_parts, raw_parts)
61
+ current_event = ""
62
+ data_parts = []
63
+ raw_parts = []
64
+ continue
65
+ raw_parts.append(stripped)
66
+ if stripped.startswith("event:"):
67
+ current_event = stripped.split(":", 1)[1].strip()
68
+ elif stripped.startswith("data:"):
69
+ data_parts.append(stripped.split(":", 1)[1].strip())
70
+
71
+ _append_event(events, current_event, data_parts, raw_parts)
72
+ return events
73
+
74
+
75
+ def parse_sse_text(text: str) -> list[SSEEvent]:
76
+ return parse_sse_lines(text.splitlines())
77
+
78
+
79
+ def _append_event(
80
+ events: list[SSEEvent],
81
+ current_event: str,
82
+ data_parts: list[str],
83
+ raw_parts: list[str],
84
+ ) -> None:
85
+ if not current_event and not data_parts:
86
+ return
87
+ data_text = "\n".join(data_parts)
88
+ data: dict[str, Any]
89
+ try:
90
+ parsed = json.loads(data_text) if data_text else {}
91
+ data = parsed if isinstance(parsed, dict) else {"value": parsed}
92
+ except json.JSONDecodeError:
93
+ data = {"raw": data_text}
94
+ events.append(SSEEvent(current_event, data, "\n".join(raw_parts)))
95
+
96
+
97
+ def assert_anthropic_stream_contract(
98
+ events: list[SSEEvent], *, allow_error: bool = False
99
+ ) -> None:
100
+ """Check minimal Anthropic-style SSE invariants: start/stop, block nesting.
101
+
102
+ Does *not* assert strict event ordering (e.g. :class:`message_delta` vs
103
+ content blocks) beyond presence of a final ``message_stop``; stricter
104
+ ordering can be tested in product or transport-specific suites.
105
+ """
106
+ assert events, "stream produced no SSE events"
107
+ event_names = [event.event for event in events]
108
+ assert "message_start" in event_names, event_names
109
+ assert event_names[-1] == "message_stop", event_names
110
+
111
+ open_blocks: dict[int, str] = {}
112
+ seen_blocks: set[int] = set()
113
+ for event in events:
114
+ if event.event == "error" and not allow_error:
115
+ raise AssertionError(f"unexpected SSE error event: {event.data}")
116
+
117
+ if event.event == "content_block_start":
118
+ index = event_index(event)
119
+ block = event.data.get("content_block", {})
120
+ assert isinstance(block, dict), event.data
121
+ block_type = str(block.get("type", ""))
122
+ assert block_type in _ALLOWED_BLOCK_START_TYPES, event.data
123
+ assert index not in open_blocks, f"block {index} started twice"
124
+ assert index not in seen_blocks, f"block {index} reused after stop"
125
+ if block_type == "text" and str(block.get("text", "")).strip():
126
+ storage = "text_eager"
127
+ else:
128
+ storage = block_type
129
+ open_blocks[index] = storage
130
+ seen_blocks.add(index)
131
+ continue
132
+
133
+ if event.event == "content_block_delta":
134
+ index = event_index(event)
135
+ assert index in open_blocks, f"delta for unopened block {index}"
136
+ kind = open_blocks[index]
137
+ assert kind not in _NO_DELTA_BLOCK_KINDS, (
138
+ f"unexpected delta for start/stop-only block {kind} at index {index}"
139
+ )
140
+ delta = event.data.get("delta", {})
141
+ assert isinstance(delta, dict), event.data
142
+ delta_type = str(delta.get("type", ""))
143
+ if kind == "thinking":
144
+ assert delta_type in (
145
+ "thinking_delta",
146
+ "signature_delta",
147
+ ), f"block {index} is {kind}, got {delta_type}"
148
+ continue
149
+ expected = {
150
+ "text": "text_delta",
151
+ "tool_use": "input_json_delta",
152
+ }[kind]
153
+ assert delta_type == expected, f"block {index} is {kind}, got {delta_type}"
154
+ continue
155
+
156
+ if event.event == "content_block_stop":
157
+ index = event_index(event)
158
+ assert index in open_blocks, f"stop for unopened block {index}"
159
+ open_blocks.pop(index)
160
+
161
+ assert not open_blocks, f"unclosed blocks: {open_blocks}"
162
+ assert seen_blocks, "stream did not emit any content blocks"
163
+
164
+
165
+ def event_names(events: list[SSEEvent]) -> list[str]:
166
+ return [event.event for event in events]
167
+
168
+
169
+ def text_content(events: list[SSEEvent]) -> str:
170
+ parts: list[str] = []
171
+ for event in events:
172
+ if event.event == "content_block_start":
173
+ block = event.data.get("content_block", {})
174
+ if isinstance(block, dict) and block.get("type") == "text":
175
+ eager = str(block.get("text", ""))
176
+ if eager:
177
+ parts.append(eager)
178
+ delta = event.data.get("delta", {})
179
+ if isinstance(delta, dict) and delta.get("type") == "text_delta":
180
+ parts.append(str(delta.get("text", "")))
181
+ return "".join(parts)
182
+
183
+
184
+ def thinking_content(events: list[SSEEvent]) -> str:
185
+ parts: list[str] = []
186
+ for event in events:
187
+ delta = event.data.get("delta", {})
188
+ if isinstance(delta, dict) and delta.get("type") == "thinking_delta":
189
+ parts.append(str(delta.get("thinking", "")))
190
+ return "".join(parts)
191
+
192
+
193
+ def has_tool_use(events: list[SSEEvent]) -> bool:
194
+ for event in events:
195
+ block = event.data.get("content_block", {})
196
+ if isinstance(block, dict) and block.get("type") == "tool_use":
197
+ return True
198
+ return False
199
+
200
+
201
+ def event_index(event: SSEEvent) -> int:
202
+ """Return the content block ``index`` field from an SSE payload (strict)."""
203
+ value = event.data.get("index")
204
+ assert isinstance(value, int), event.data
205
+ return value