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