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