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.
- api/__init__.py +17 -0
- api/admin_config.py +1303 -0
- api/admin_routes.py +287 -0
- api/admin_static/admin.css +459 -0
- api/admin_static/admin.js +497 -0
- api/admin_static/index.html +77 -0
- api/admin_urls.py +34 -0
- api/app.py +194 -0
- api/command_utils.py +164 -0
- api/dependencies.py +144 -0
- api/detection.py +152 -0
- api/gateway_model_ids.py +54 -0
- api/model_catalog.py +133 -0
- api/model_router.py +125 -0
- api/models/__init__.py +45 -0
- api/models/anthropic.py +234 -0
- api/models/openai_responses.py +28 -0
- api/models/responses.py +60 -0
- api/optimization_handlers.py +154 -0
- api/request_pipeline.py +424 -0
- api/routes.py +156 -0
- api/runtime.py +334 -0
- api/validation_log.py +48 -0
- api/web_server_tools.py +22 -0
- api/web_tools/__init__.py +17 -0
- api/web_tools/constants.py +15 -0
- api/web_tools/egress.py +99 -0
- api/web_tools/outbound.py +278 -0
- api/web_tools/parsers.py +104 -0
- api/web_tools/request.py +87 -0
- api/web_tools/streaming.py +206 -0
- cli/__init__.py +5 -0
- cli/claude_env.py +12 -0
- cli/entrypoints.py +166 -0
- cli/env.example +209 -0
- cli/launchers/__init__.py +1 -0
- cli/launchers/claude.py +84 -0
- cli/launchers/codex.py +204 -0
- cli/launchers/codex_model_catalog.py +186 -0
- cli/launchers/common.py +93 -0
- cli/managed/__init__.py +6 -0
- cli/managed/claude.py +215 -0
- cli/managed/manager.py +157 -0
- cli/managed/session.py +260 -0
- cli/process_registry.py +78 -0
- config/__init__.py +5 -0
- config/constants.py +13 -0
- config/logging_config.py +159 -0
- config/nim.py +118 -0
- config/paths.py +91 -0
- config/provider_catalog.py +259 -0
- config/provider_ids.py +7 -0
- config/settings.py +538 -0
- core/__init__.py +1 -0
- core/anthropic/__init__.py +46 -0
- core/anthropic/content.py +31 -0
- core/anthropic/conversion.py +587 -0
- core/anthropic/emitted_sse_tracker.py +346 -0
- core/anthropic/errors.py +70 -0
- core/anthropic/native_messages_request.py +280 -0
- core/anthropic/native_sse_block_policy.py +313 -0
- core/anthropic/provider_stream_error.py +34 -0
- core/anthropic/server_tool_sse.py +14 -0
- core/anthropic/sse.py +440 -0
- core/anthropic/stream_contracts.py +205 -0
- core/anthropic/stream_recovery.py +346 -0
- core/anthropic/stream_recovery_session.py +133 -0
- core/anthropic/thinking.py +140 -0
- core/anthropic/tokens.py +117 -0
- core/anthropic/tools.py +212 -0
- core/anthropic/utils.py +9 -0
- core/openai_responses/__init__.py +5 -0
- core/openai_responses/adapter.py +31 -0
- core/openai_responses/anthropic_sse.py +59 -0
- core/openai_responses/errors.py +22 -0
- core/openai_responses/events.py +19 -0
- core/openai_responses/ids.py +21 -0
- core/openai_responses/input.py +258 -0
- core/openai_responses/items.py +37 -0
- core/openai_responses/reasoning.py +52 -0
- core/openai_responses/stream.py +25 -0
- core/openai_responses/stream_state.py +654 -0
- core/openai_responses/tools.py +374 -0
- core/openai_responses/usage.py +37 -0
- core/rate_limit.py +60 -0
- core/trace.py +216 -0
- devcopilot-0.2.0.dist-info/METADATA +687 -0
- devcopilot-0.2.0.dist-info/RECORD +189 -0
- devcopilot-0.2.0.dist-info/WHEEL +4 -0
- devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
- devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
- messaging/__init__.py +26 -0
- messaging/cli_event_constants.py +67 -0
- messaging/command_context.py +66 -0
- messaging/command_dispatcher.py +37 -0
- messaging/commands.py +275 -0
- messaging/event_parser.py +181 -0
- messaging/limiter.py +300 -0
- messaging/models.py +36 -0
- messaging/node_event_pipeline.py +127 -0
- messaging/node_runner.py +342 -0
- messaging/platforms/__init__.py +15 -0
- messaging/platforms/base.py +228 -0
- messaging/platforms/discord.py +567 -0
- messaging/platforms/factory.py +103 -0
- messaging/platforms/outbox.py +144 -0
- messaging/platforms/telegram.py +688 -0
- messaging/platforms/voice_flow.py +295 -0
- messaging/rendering/__init__.py +3 -0
- messaging/rendering/discord_markdown.py +318 -0
- messaging/rendering/markdown_tables.py +49 -0
- messaging/rendering/profiles.py +55 -0
- messaging/rendering/telegram_markdown.py +327 -0
- messaging/safe_diagnostics.py +17 -0
- messaging/session.py +334 -0
- messaging/transcript.py +581 -0
- messaging/transcription.py +164 -0
- messaging/trees/__init__.py +15 -0
- messaging/trees/data.py +482 -0
- messaging/trees/manager.py +433 -0
- messaging/trees/processor.py +179 -0
- messaging/trees/repository.py +177 -0
- messaging/turn_intake.py +235 -0
- messaging/ui_updates.py +101 -0
- messaging/voice.py +76 -0
- messaging/workflow.py +200 -0
- providers/__init__.py +31 -0
- providers/base.py +152 -0
- providers/cerebras/__init__.py +7 -0
- providers/cerebras/client.py +31 -0
- providers/cerebras/request.py +55 -0
- providers/codestral/__init__.py +7 -0
- providers/codestral/client.py +34 -0
- providers/deepseek/__init__.py +11 -0
- providers/deepseek/client.py +51 -0
- providers/deepseek/request.py +475 -0
- providers/defaults.py +41 -0
- providers/error_mapping.py +309 -0
- providers/exceptions.py +113 -0
- providers/fireworks/__init__.py +5 -0
- providers/fireworks/client.py +45 -0
- providers/fireworks/request.py +48 -0
- providers/gemini/__init__.py +7 -0
- providers/gemini/client.py +49 -0
- providers/gemini/request.py +199 -0
- providers/groq/__init__.py +7 -0
- providers/groq/client.py +31 -0
- providers/groq/request.py +83 -0
- providers/kimi/__init__.py +10 -0
- providers/kimi/client.py +53 -0
- providers/kimi/request.py +42 -0
- providers/llamacpp/__init__.py +3 -0
- providers/llamacpp/client.py +16 -0
- providers/lmstudio/__init__.py +5 -0
- providers/lmstudio/client.py +16 -0
- providers/mistral/__init__.py +7 -0
- providers/mistral/client.py +31 -0
- providers/mistral/request.py +37 -0
- providers/model_listing.py +133 -0
- providers/nvidia_nim/__init__.py +7 -0
- providers/nvidia_nim/client.py +91 -0
- providers/nvidia_nim/request.py +430 -0
- providers/nvidia_nim/voice.py +95 -0
- providers/ollama/__init__.py +7 -0
- providers/ollama/client.py +39 -0
- providers/open_router/__init__.py +7 -0
- providers/open_router/client.py +124 -0
- providers/open_router/request.py +42 -0
- providers/opencode/__init__.py +11 -0
- providers/opencode/client.py +31 -0
- providers/opencode/request.py +35 -0
- providers/rate_limit.py +300 -0
- providers/registry.py +527 -0
- providers/transports/__init__.py +1 -0
- providers/transports/anthropic_messages/__init__.py +5 -0
- providers/transports/anthropic_messages/http.py +118 -0
- providers/transports/anthropic_messages/recovery.py +206 -0
- providers/transports/anthropic_messages/stream.py +295 -0
- providers/transports/anthropic_messages/transport.py +236 -0
- providers/transports/openai_chat/__init__.py +5 -0
- providers/transports/openai_chat/recovery.py +217 -0
- providers/transports/openai_chat/stream.py +384 -0
- providers/transports/openai_chat/tool_calls.py +293 -0
- providers/transports/openai_chat/transport.py +156 -0
- providers/wafer/__init__.py +10 -0
- providers/wafer/client.py +50 -0
- providers/zai/__init__.py +10 -0
- providers/zai/client.py +46 -0
- 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
|