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,346 @@
|
|
|
1
|
+
"""Track content-block state for native Anthropic SSE strings we emit to clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from core.anthropic.sse import SSEBuilder, format_sse_event
|
|
12
|
+
from core.anthropic.stream_contracts import SSEEvent, parse_sse_lines
|
|
13
|
+
from core.anthropic.stream_recovery import (
|
|
14
|
+
ToolSchema,
|
|
15
|
+
accept_tool_json_repair,
|
|
16
|
+
continuation_suffix,
|
|
17
|
+
parse_complete_tool_input,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class EmittedBlockState:
|
|
23
|
+
"""Tracked downstream block payload emitted to the client."""
|
|
24
|
+
|
|
25
|
+
index: int
|
|
26
|
+
block_type: str
|
|
27
|
+
open: bool = True
|
|
28
|
+
tool_id: str = ""
|
|
29
|
+
name: str = ""
|
|
30
|
+
parts: list[str] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def content(self) -> str:
|
|
34
|
+
return "".join(self.parts)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EmittedNativeSseTracker:
|
|
38
|
+
"""Parse emitted SSE frames so mid-stream errors can close blocks and pick a fresh index."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._buf = ""
|
|
42
|
+
self._open_stack: list[int] = []
|
|
43
|
+
self._max_index = -1
|
|
44
|
+
self._blocks: dict[int, EmittedBlockState] = {}
|
|
45
|
+
self.message_id: str | None = None
|
|
46
|
+
self.model: str = ""
|
|
47
|
+
self.stop_reason: str | None = None
|
|
48
|
+
self.message_stopped = False
|
|
49
|
+
|
|
50
|
+
def feed(self, chunk: str) -> None:
|
|
51
|
+
"""Record SSE frames completed by ``chunk`` (handles splitting across reads)."""
|
|
52
|
+
self._buf += chunk
|
|
53
|
+
while True:
|
|
54
|
+
sep = self._buf.find("\n\n")
|
|
55
|
+
if sep < 0:
|
|
56
|
+
break
|
|
57
|
+
frame = self._buf[:sep]
|
|
58
|
+
self._buf = self._buf[sep + 2 :]
|
|
59
|
+
if not frame.strip():
|
|
60
|
+
continue
|
|
61
|
+
for event in parse_sse_lines(frame.splitlines()):
|
|
62
|
+
self._observe(event)
|
|
63
|
+
|
|
64
|
+
def _observe(self, event: SSEEvent) -> None:
|
|
65
|
+
if event.event == "message_start":
|
|
66
|
+
message = event.data.get("message")
|
|
67
|
+
if isinstance(message, dict):
|
|
68
|
+
mid = message.get("id")
|
|
69
|
+
if isinstance(mid, str) and mid:
|
|
70
|
+
self.message_id = mid
|
|
71
|
+
model = message.get("model")
|
|
72
|
+
if isinstance(model, str) and model:
|
|
73
|
+
self.model = model
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
if event.event == "content_block_start":
|
|
77
|
+
raw_index = event.data.get("index")
|
|
78
|
+
if not isinstance(raw_index, int):
|
|
79
|
+
return
|
|
80
|
+
idx = raw_index
|
|
81
|
+
self._max_index = max(self._max_index, idx)
|
|
82
|
+
self._open_stack.append(idx)
|
|
83
|
+
block = event.data.get("content_block")
|
|
84
|
+
if isinstance(block, dict):
|
|
85
|
+
block_type = str(block.get("type", ""))
|
|
86
|
+
state = EmittedBlockState(index=idx, block_type=block_type)
|
|
87
|
+
if block_type == "tool_use":
|
|
88
|
+
tool_id = block.get("id")
|
|
89
|
+
name = block.get("name")
|
|
90
|
+
state.tool_id = tool_id if isinstance(tool_id, str) else ""
|
|
91
|
+
state.name = name if isinstance(name, str) else ""
|
|
92
|
+
elif block_type == "text":
|
|
93
|
+
text = block.get("text")
|
|
94
|
+
if isinstance(text, str) and text:
|
|
95
|
+
state.parts.append(text)
|
|
96
|
+
elif block_type == "thinking":
|
|
97
|
+
thinking = block.get("thinking")
|
|
98
|
+
if isinstance(thinking, str) and thinking:
|
|
99
|
+
state.parts.append(thinking)
|
|
100
|
+
self._blocks[idx] = state
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if event.event == "content_block_delta":
|
|
104
|
+
raw_index = event.data.get("index")
|
|
105
|
+
if not isinstance(raw_index, int):
|
|
106
|
+
return
|
|
107
|
+
idx = raw_index
|
|
108
|
+
state = self._blocks.get(idx)
|
|
109
|
+
delta = event.data.get("delta")
|
|
110
|
+
if state is not None and isinstance(delta, dict):
|
|
111
|
+
if state.block_type == "text":
|
|
112
|
+
text = delta.get("text")
|
|
113
|
+
if isinstance(text, str):
|
|
114
|
+
state.parts.append(text)
|
|
115
|
+
elif state.block_type == "thinking":
|
|
116
|
+
thinking = delta.get("thinking")
|
|
117
|
+
if isinstance(thinking, str):
|
|
118
|
+
state.parts.append(thinking)
|
|
119
|
+
elif state.block_type == "tool_use":
|
|
120
|
+
partial = delta.get("partial_json")
|
|
121
|
+
if isinstance(partial, str):
|
|
122
|
+
state.parts.append(partial)
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
if event.event == "content_block_stop":
|
|
126
|
+
raw_index = event.data.get("index")
|
|
127
|
+
if not isinstance(raw_index, int):
|
|
128
|
+
return
|
|
129
|
+
idx = raw_index
|
|
130
|
+
if self._open_stack and self._open_stack[-1] == idx:
|
|
131
|
+
self._open_stack.pop()
|
|
132
|
+
else:
|
|
133
|
+
with suppress(ValueError):
|
|
134
|
+
self._open_stack.remove(idx)
|
|
135
|
+
state = self._blocks.get(idx)
|
|
136
|
+
if state is not None:
|
|
137
|
+
state.open = False
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
if event.event == "message_delta":
|
|
141
|
+
delta = event.data.get("delta")
|
|
142
|
+
if isinstance(delta, dict):
|
|
143
|
+
stop_reason = delta.get("stop_reason")
|
|
144
|
+
if isinstance(stop_reason, str):
|
|
145
|
+
self.stop_reason = stop_reason
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
if event.event == "message_stop":
|
|
149
|
+
self.message_stopped = True
|
|
150
|
+
|
|
151
|
+
def next_content_index(self) -> int:
|
|
152
|
+
"""Next unused content block index based on emitted starts."""
|
|
153
|
+
return self._max_index + 1
|
|
154
|
+
|
|
155
|
+
def iter_close_unclosed_blocks(self) -> Iterator[str]:
|
|
156
|
+
"""Yield ``content_block_stop`` events for blocks that were started but not stopped."""
|
|
157
|
+
while self._open_stack:
|
|
158
|
+
idx = self._open_stack.pop()
|
|
159
|
+
state = self._blocks.get(idx)
|
|
160
|
+
if state is not None:
|
|
161
|
+
state.open = False
|
|
162
|
+
yield format_sse_event(
|
|
163
|
+
"content_block_stop",
|
|
164
|
+
{"type": "content_block_stop", "index": idx},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def emitted_text(self) -> str:
|
|
168
|
+
return "".join(
|
|
169
|
+
block.content
|
|
170
|
+
for block in self._blocks.values()
|
|
171
|
+
if block.block_type == "text"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def emitted_thinking(self) -> str:
|
|
175
|
+
return "".join(
|
|
176
|
+
block.content
|
|
177
|
+
for block in self._blocks.values()
|
|
178
|
+
if block.block_type == "thinking"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def has_tool_block(self) -> bool:
|
|
182
|
+
return any(block.block_type == "tool_use" for block in self._blocks.values())
|
|
183
|
+
|
|
184
|
+
def has_content_block(self) -> bool:
|
|
185
|
+
return bool(self._blocks)
|
|
186
|
+
|
|
187
|
+
def has_terminal_message(self) -> bool:
|
|
188
|
+
return self.message_stopped
|
|
189
|
+
|
|
190
|
+
def tool_blocks(self) -> list[EmittedBlockState]:
|
|
191
|
+
return [
|
|
192
|
+
block for block in self._blocks.values() if block.block_type == "tool_use"
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
def can_salvage_tool_use(self, schemas: dict[str, ToolSchema]) -> bool:
|
|
196
|
+
tool_blocks = self.tool_blocks()
|
|
197
|
+
if not tool_blocks:
|
|
198
|
+
return False
|
|
199
|
+
for block in tool_blocks:
|
|
200
|
+
if not block.tool_id or not block.name:
|
|
201
|
+
return False
|
|
202
|
+
if parse_complete_tool_input(block.content, block.name, schemas) is None:
|
|
203
|
+
return False
|
|
204
|
+
return True
|
|
205
|
+
|
|
206
|
+
def append_text_suffix(self, suffix: str) -> Iterator[str]:
|
|
207
|
+
if not suffix:
|
|
208
|
+
return
|
|
209
|
+
active = self._last_open_block("text")
|
|
210
|
+
if active is None:
|
|
211
|
+
index = self.next_content_index()
|
|
212
|
+
self._max_index = max(self._max_index, index)
|
|
213
|
+
active = EmittedBlockState(index=index, block_type="text")
|
|
214
|
+
self._blocks[index] = active
|
|
215
|
+
self._open_stack.append(index)
|
|
216
|
+
yield format_sse_event(
|
|
217
|
+
"content_block_start",
|
|
218
|
+
{
|
|
219
|
+
"type": "content_block_start",
|
|
220
|
+
"index": index,
|
|
221
|
+
"content_block": {"type": "text", "text": ""},
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
active.parts.append(suffix)
|
|
225
|
+
yield format_sse_event(
|
|
226
|
+
"content_block_delta",
|
|
227
|
+
{
|
|
228
|
+
"type": "content_block_delta",
|
|
229
|
+
"index": active.index,
|
|
230
|
+
"delta": {"type": "text_delta", "text": suffix},
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def append_thinking_suffix(self, suffix: str) -> Iterator[str]:
|
|
235
|
+
if not suffix:
|
|
236
|
+
return
|
|
237
|
+
active = self._last_open_block("thinking")
|
|
238
|
+
if active is None:
|
|
239
|
+
index = self.next_content_index()
|
|
240
|
+
self._max_index = max(self._max_index, index)
|
|
241
|
+
active = EmittedBlockState(index=index, block_type="thinking")
|
|
242
|
+
self._blocks[index] = active
|
|
243
|
+
self._open_stack.append(index)
|
|
244
|
+
yield format_sse_event(
|
|
245
|
+
"content_block_start",
|
|
246
|
+
{
|
|
247
|
+
"type": "content_block_start",
|
|
248
|
+
"index": index,
|
|
249
|
+
"content_block": {"type": "thinking", "thinking": ""},
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
active.parts.append(suffix)
|
|
253
|
+
yield format_sse_event(
|
|
254
|
+
"content_block_delta",
|
|
255
|
+
{
|
|
256
|
+
"type": "content_block_delta",
|
|
257
|
+
"index": active.index,
|
|
258
|
+
"delta": {"type": "thinking_delta", "thinking": suffix},
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def append_tool_repair_suffix(
|
|
263
|
+
self,
|
|
264
|
+
tool_index: int,
|
|
265
|
+
suffix: str,
|
|
266
|
+
) -> Iterator[str]:
|
|
267
|
+
tool_blocks = self.tool_blocks()
|
|
268
|
+
if tool_index >= len(tool_blocks) or not suffix:
|
|
269
|
+
return
|
|
270
|
+
block = tool_blocks[tool_index]
|
|
271
|
+
block.parts.append(suffix)
|
|
272
|
+
yield format_sse_event(
|
|
273
|
+
"content_block_delta",
|
|
274
|
+
{
|
|
275
|
+
"type": "content_block_delta",
|
|
276
|
+
"index": block.index,
|
|
277
|
+
"delta": {"type": "input_json_delta", "partial_json": suffix},
|
|
278
|
+
},
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def iter_success_tail(self, stop_reason: str) -> Iterator[str]:
|
|
282
|
+
yield from self.iter_close_unclosed_blocks()
|
|
283
|
+
if self.stop_reason is None:
|
|
284
|
+
yield format_sse_event(
|
|
285
|
+
"message_delta",
|
|
286
|
+
{
|
|
287
|
+
"type": "message_delta",
|
|
288
|
+
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
|
|
289
|
+
"usage": {"input_tokens": 0, "output_tokens": 1},
|
|
290
|
+
},
|
|
291
|
+
)
|
|
292
|
+
if not self.message_stopped:
|
|
293
|
+
yield format_sse_event("message_stop", {"type": "message_stop"})
|
|
294
|
+
|
|
295
|
+
def accept_tool_repair(
|
|
296
|
+
self,
|
|
297
|
+
tool_index: int,
|
|
298
|
+
candidate: str,
|
|
299
|
+
schemas: dict[str, ToolSchema],
|
|
300
|
+
) -> str | None:
|
|
301
|
+
tool_blocks = self.tool_blocks()
|
|
302
|
+
if tool_index >= len(tool_blocks):
|
|
303
|
+
return None
|
|
304
|
+
block = tool_blocks[tool_index]
|
|
305
|
+
repair = accept_tool_json_repair(
|
|
306
|
+
block.content,
|
|
307
|
+
candidate,
|
|
308
|
+
tool_name=block.name,
|
|
309
|
+
schemas=schemas,
|
|
310
|
+
)
|
|
311
|
+
return repair.suffix if repair is not None else None
|
|
312
|
+
|
|
313
|
+
def continuation_text_suffix(self, candidate: str) -> str | None:
|
|
314
|
+
return continuation_suffix(self.emitted_text(), candidate)
|
|
315
|
+
|
|
316
|
+
def continuation_thinking_suffix(self, candidate: str) -> str | None:
|
|
317
|
+
return continuation_suffix(self.emitted_thinking(), candidate)
|
|
318
|
+
|
|
319
|
+
def iter_midstream_error_tail(
|
|
320
|
+
self,
|
|
321
|
+
error_message: str,
|
|
322
|
+
*,
|
|
323
|
+
request: Any,
|
|
324
|
+
input_tokens: int,
|
|
325
|
+
log_raw_sse_events: bool,
|
|
326
|
+
) -> Iterator[str]:
|
|
327
|
+
"""Close dangling blocks, emit a text error block at a fresh index, then message tail."""
|
|
328
|
+
mid = self.message_id or f"msg_{uuid.uuid4()}"
|
|
329
|
+
model = self.model or (getattr(request, "model", "") or "")
|
|
330
|
+
sse = SSEBuilder(
|
|
331
|
+
mid,
|
|
332
|
+
model,
|
|
333
|
+
input_tokens,
|
|
334
|
+
log_raw_events=log_raw_sse_events,
|
|
335
|
+
)
|
|
336
|
+
sse.blocks.next_index = self.next_content_index()
|
|
337
|
+
yield from sse.emit_error(error_message)
|
|
338
|
+
yield sse.message_delta("end_turn", 1)
|
|
339
|
+
yield sse.message_stop()
|
|
340
|
+
|
|
341
|
+
def _last_open_block(self, block_type: str) -> EmittedBlockState | None:
|
|
342
|
+
for index in reversed(self._open_stack):
|
|
343
|
+
block = self._blocks.get(index)
|
|
344
|
+
if block is not None and block.block_type == block_type and block.open:
|
|
345
|
+
return block
|
|
346
|
+
return None
|
core/anthropic/errors.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""User-facing error formatting shared by API, providers, and integrations."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import openai
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_user_facing_error_message(
|
|
8
|
+
e: Exception,
|
|
9
|
+
*,
|
|
10
|
+
read_timeout_s: float | None = None,
|
|
11
|
+
) -> str:
|
|
12
|
+
"""Return a readable, non-empty error message for users.
|
|
13
|
+
|
|
14
|
+
Known transport and OpenAI SDK exception types are mapped to stable wording
|
|
15
|
+
before falling back to ``str(e)``, so empty or noisy SDK messages do not skip
|
|
16
|
+
the mapped path.
|
|
17
|
+
"""
|
|
18
|
+
if isinstance(e, httpx.ReadTimeout):
|
|
19
|
+
if read_timeout_s is not None:
|
|
20
|
+
return f"Provider request timed out after {read_timeout_s:g}s."
|
|
21
|
+
return "Provider request timed out."
|
|
22
|
+
if isinstance(e, httpx.ConnectTimeout):
|
|
23
|
+
return "Could not connect to provider."
|
|
24
|
+
if isinstance(e, TimeoutError):
|
|
25
|
+
if read_timeout_s is not None:
|
|
26
|
+
return f"Provider request timed out after {read_timeout_s:g}s."
|
|
27
|
+
return "Request timed out."
|
|
28
|
+
|
|
29
|
+
if isinstance(e, openai.RateLimitError):
|
|
30
|
+
return "Provider rate limit reached. Please retry shortly."
|
|
31
|
+
if isinstance(e, openai.AuthenticationError):
|
|
32
|
+
return "Provider authentication failed. Check API key."
|
|
33
|
+
if isinstance(e, openai.BadRequestError):
|
|
34
|
+
return "Invalid request sent to provider."
|
|
35
|
+
|
|
36
|
+
name = type(e).__name__
|
|
37
|
+
status_code = getattr(e, "status_code", None)
|
|
38
|
+
if name == "RateLimitError":
|
|
39
|
+
return "Provider rate limit reached. Please retry shortly."
|
|
40
|
+
if name == "AuthenticationError":
|
|
41
|
+
return "Provider authentication failed. Check API key."
|
|
42
|
+
if name == "InvalidRequestError":
|
|
43
|
+
return "Invalid request sent to provider."
|
|
44
|
+
if name == "OverloadedError":
|
|
45
|
+
return "Provider is currently overloaded. Please retry."
|
|
46
|
+
if name == "APIError":
|
|
47
|
+
if status_code in (502, 503, 504):
|
|
48
|
+
return "Provider is temporarily unavailable. Please retry."
|
|
49
|
+
return "Provider API request failed."
|
|
50
|
+
if name.endswith("ProviderError") or name == "ProviderError":
|
|
51
|
+
return "Provider request failed."
|
|
52
|
+
|
|
53
|
+
message = str(e).strip()
|
|
54
|
+
if message:
|
|
55
|
+
return message
|
|
56
|
+
|
|
57
|
+
return "Provider request failed unexpectedly."
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def format_user_error_preview(exc: Exception, *, max_len: int = 200) -> str:
|
|
61
|
+
"""Truncate a user-facing error string for short chat replies."""
|
|
62
|
+
return get_user_facing_error_message(exc)[:max_len]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def append_request_id(message: str, request_id: str | None) -> str:
|
|
66
|
+
"""Append request_id suffix when available."""
|
|
67
|
+
base = message.strip() or "Provider request failed unexpectedly."
|
|
68
|
+
if request_id:
|
|
69
|
+
return f"{base} (request_id={request_id})"
|
|
70
|
+
return base
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Native Anthropic Messages request body construction (JSON-ready dicts).
|
|
2
|
+
|
|
3
|
+
Provider adapters supply policy via parameters (defaults, OpenRouter post-steps).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
_REQUEST_FIELDS = (
|
|
14
|
+
"model",
|
|
15
|
+
"messages",
|
|
16
|
+
"system",
|
|
17
|
+
"max_tokens",
|
|
18
|
+
"stop_sequences",
|
|
19
|
+
"stream",
|
|
20
|
+
"temperature",
|
|
21
|
+
"top_p",
|
|
22
|
+
"top_k",
|
|
23
|
+
"metadata",
|
|
24
|
+
"tools",
|
|
25
|
+
"tool_choice",
|
|
26
|
+
"thinking",
|
|
27
|
+
"context_management",
|
|
28
|
+
"output_config",
|
|
29
|
+
"mcp_servers",
|
|
30
|
+
"extra_body",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Keys that would override routed canonical request fields if merged from ``extra_body``.
|
|
34
|
+
_OPENROUTER_EXTRA_BODY_FORBIDDEN_KEYS = frozenset(
|
|
35
|
+
{
|
|
36
|
+
"model",
|
|
37
|
+
"messages",
|
|
38
|
+
"system",
|
|
39
|
+
"tools",
|
|
40
|
+
"tool_choice",
|
|
41
|
+
"stream",
|
|
42
|
+
"max_tokens",
|
|
43
|
+
"temperature",
|
|
44
|
+
"top_p",
|
|
45
|
+
"top_k",
|
|
46
|
+
"metadata",
|
|
47
|
+
"stop_sequences",
|
|
48
|
+
"context_management",
|
|
49
|
+
"output_config",
|
|
50
|
+
"mcp_servers",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OpenRouterExtraBodyError(ValueError):
|
|
56
|
+
"""``extra_body`` contained reserved keys that would override canonical fields."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_openrouter_extra_body(extra: Any) -> None:
|
|
60
|
+
"""Reject ``extra_body`` keys that must not override routed request fields."""
|
|
61
|
+
if not isinstance(extra, dict) or not extra:
|
|
62
|
+
return
|
|
63
|
+
bad = _OPENROUTER_EXTRA_BODY_FORBIDDEN_KEYS & extra.keys()
|
|
64
|
+
if bad:
|
|
65
|
+
raise OpenRouterExtraBodyError(
|
|
66
|
+
f"extra_body must not override canonical request fields: {sorted(bad)}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_INTERNAL_FIELDS = {
|
|
71
|
+
"thinking",
|
|
72
|
+
"extra_body",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _serialize_value(value: Any) -> Any:
|
|
77
|
+
"""Convert Pydantic models and lightweight objects into JSON-ready values."""
|
|
78
|
+
if isinstance(value, BaseModel):
|
|
79
|
+
return value.model_dump(exclude_none=True)
|
|
80
|
+
if isinstance(value, dict):
|
|
81
|
+
return {
|
|
82
|
+
key: _serialize_value(item)
|
|
83
|
+
for key, item in value.items()
|
|
84
|
+
if item is not None
|
|
85
|
+
}
|
|
86
|
+
if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
|
|
87
|
+
return [_serialize_value(item) for item in value]
|
|
88
|
+
if value is None or isinstance(value, str | int | float | bool):
|
|
89
|
+
return value
|
|
90
|
+
if hasattr(value, "__dict__"):
|
|
91
|
+
return {
|
|
92
|
+
key: _serialize_value(item)
|
|
93
|
+
for key, item in vars(value).items()
|
|
94
|
+
if not key.startswith("_") and item is not None
|
|
95
|
+
}
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _dump_request_fields(request_data: Any) -> dict[str, Any]:
|
|
100
|
+
"""Extract the public request fields (OpenRouter-style explicit field list)."""
|
|
101
|
+
if isinstance(request_data, BaseModel):
|
|
102
|
+
raw = request_data.model_dump(exclude_none=True)
|
|
103
|
+
return {
|
|
104
|
+
field: raw[field]
|
|
105
|
+
for field in _REQUEST_FIELDS
|
|
106
|
+
if field in raw and raw[field] is not None
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
dump = getattr(request_data, "model_dump", None)
|
|
110
|
+
if callable(dump):
|
|
111
|
+
raw = dump(exclude_none=True)
|
|
112
|
+
if isinstance(raw, dict):
|
|
113
|
+
return {
|
|
114
|
+
field: raw[field]
|
|
115
|
+
for field in _REQUEST_FIELDS
|
|
116
|
+
if field in raw and raw[field] is not None
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
dumped: dict[str, Any] = {}
|
|
120
|
+
for field in _REQUEST_FIELDS:
|
|
121
|
+
value = getattr(request_data, field, None)
|
|
122
|
+
if value is not None:
|
|
123
|
+
dumped[field] = _serialize_value(value)
|
|
124
|
+
return dumped
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def dump_raw_messages_request(request_data: Any) -> dict[str, Any]:
|
|
128
|
+
"""Public JSON-ready dict of Anthropic public request fields (for native adapters)."""
|
|
129
|
+
return _dump_request_fields(request_data)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def sanitize_native_messages_thinking_policy(
|
|
133
|
+
messages: Any, *, thinking_enabled: bool
|
|
134
|
+
) -> Any:
|
|
135
|
+
"""Filter assistant message thinking blocks for upstream native Anthropic JSON.
|
|
136
|
+
|
|
137
|
+
When ``thinking_enabled`` is false, remove ``thinking`` and ``redacted_thinking``
|
|
138
|
+
history so disabled policy is not undermined by prior turns.
|
|
139
|
+
|
|
140
|
+
When true, keep ``redacted_thinking`` and signed ``thinking``; remove only
|
|
141
|
+
unsigned plain ``thinking`` blocks (not replayable).
|
|
142
|
+
"""
|
|
143
|
+
if not isinstance(messages, list):
|
|
144
|
+
return messages
|
|
145
|
+
|
|
146
|
+
sanitized_messages: list[Any] = []
|
|
147
|
+
for message in messages:
|
|
148
|
+
if not isinstance(message, dict):
|
|
149
|
+
sanitized_messages.append(message)
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
if message.get("role") != "assistant":
|
|
153
|
+
sanitized_messages.append(message)
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
content = message.get("content")
|
|
157
|
+
if not isinstance(content, list):
|
|
158
|
+
sanitized_messages.append(message)
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
if not thinking_enabled:
|
|
162
|
+
sanitized_content = [
|
|
163
|
+
block
|
|
164
|
+
for block in content
|
|
165
|
+
if not (
|
|
166
|
+
isinstance(block, dict)
|
|
167
|
+
and block.get("type") in ("thinking", "redacted_thinking")
|
|
168
|
+
)
|
|
169
|
+
]
|
|
170
|
+
else:
|
|
171
|
+
sanitized_content = [
|
|
172
|
+
block
|
|
173
|
+
for block in content
|
|
174
|
+
if not (
|
|
175
|
+
isinstance(block, dict)
|
|
176
|
+
and block.get("type") == "thinking"
|
|
177
|
+
and not isinstance(block.get("signature"), str)
|
|
178
|
+
)
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
sanitized_message = dict(message)
|
|
182
|
+
sanitized_message["content"] = sanitized_content or ""
|
|
183
|
+
sanitized_messages.append(sanitized_message)
|
|
184
|
+
|
|
185
|
+
return sanitized_messages
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _normalize_system_prompt_for_openrouter(system: Any) -> Any:
|
|
189
|
+
"""Flatten Claude SDK system blocks for OpenRouter's native endpoint."""
|
|
190
|
+
if not isinstance(system, list):
|
|
191
|
+
return system
|
|
192
|
+
|
|
193
|
+
text_parts: list[str] = []
|
|
194
|
+
for block in system:
|
|
195
|
+
if not isinstance(block, dict):
|
|
196
|
+
continue
|
|
197
|
+
if block.get("type") == "text" and isinstance(block.get("text"), str):
|
|
198
|
+
text_parts.append(block["text"])
|
|
199
|
+
return "\n\n".join(text_parts).strip() if text_parts else system
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _apply_openrouter_reasoning_policy(body: dict[str, Any], thinking_cfg: Any) -> None:
|
|
203
|
+
"""Map Anthropic thinking controls onto OpenRouter reasoning controls."""
|
|
204
|
+
reasoning = body.setdefault("reasoning", {"enabled": True})
|
|
205
|
+
if not isinstance(reasoning, dict):
|
|
206
|
+
return
|
|
207
|
+
reasoning.setdefault("enabled", True)
|
|
208
|
+
if not isinstance(thinking_cfg, dict):
|
|
209
|
+
return
|
|
210
|
+
budget_tokens = thinking_cfg.get("budget_tokens")
|
|
211
|
+
if isinstance(budget_tokens, int):
|
|
212
|
+
reasoning.setdefault("max_tokens", budget_tokens)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def build_base_native_anthropic_request_body(
|
|
216
|
+
request: Any,
|
|
217
|
+
*,
|
|
218
|
+
default_max_tokens: int,
|
|
219
|
+
thinking_enabled: bool,
|
|
220
|
+
) -> dict[str, Any]:
|
|
221
|
+
"""Serialize a Pydantic messages request to a generic native Anthropic body."""
|
|
222
|
+
body = dump_raw_messages_request(request)
|
|
223
|
+
|
|
224
|
+
body.pop("extra_body", None)
|
|
225
|
+
|
|
226
|
+
if "thinking" in body:
|
|
227
|
+
thinking_cfg = body.pop("thinking")
|
|
228
|
+
if thinking_enabled and isinstance(thinking_cfg, dict):
|
|
229
|
+
thinking_payload: dict[str, Any] = {"type": "enabled"}
|
|
230
|
+
budget_tokens = thinking_cfg.get("budget_tokens")
|
|
231
|
+
if isinstance(budget_tokens, int):
|
|
232
|
+
thinking_payload["budget_tokens"] = budget_tokens
|
|
233
|
+
body["thinking"] = thinking_payload
|
|
234
|
+
|
|
235
|
+
if "max_tokens" not in body:
|
|
236
|
+
body["max_tokens"] = default_max_tokens
|
|
237
|
+
|
|
238
|
+
if "messages" in body:
|
|
239
|
+
body["messages"] = sanitize_native_messages_thinking_policy(
|
|
240
|
+
body["messages"],
|
|
241
|
+
thinking_enabled=thinking_enabled,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return body
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def build_openrouter_native_request_body(
|
|
248
|
+
request_data: Any,
|
|
249
|
+
*,
|
|
250
|
+
thinking_enabled: bool,
|
|
251
|
+
default_max_tokens: int,
|
|
252
|
+
) -> dict[str, Any]:
|
|
253
|
+
"""Build an Anthropic-format request body for OpenRouter (policy hooks built-in)."""
|
|
254
|
+
dumped_request = _dump_request_fields(request_data)
|
|
255
|
+
request_extra = dumped_request.pop("extra_body", None)
|
|
256
|
+
thinking_cfg = dumped_request.get("thinking")
|
|
257
|
+
body: dict[str, Any] = {
|
|
258
|
+
key: value
|
|
259
|
+
for key, value in dumped_request.items()
|
|
260
|
+
if key not in _INTERNAL_FIELDS
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if isinstance(request_extra, dict):
|
|
264
|
+
validate_openrouter_extra_body(request_extra)
|
|
265
|
+
body.update(request_extra)
|
|
266
|
+
|
|
267
|
+
body["messages"] = sanitize_native_messages_thinking_policy(
|
|
268
|
+
body.get("messages"),
|
|
269
|
+
thinking_enabled=thinking_enabled,
|
|
270
|
+
)
|
|
271
|
+
if "system" in body:
|
|
272
|
+
body["system"] = _normalize_system_prompt_for_openrouter(body["system"])
|
|
273
|
+
body["stream"] = True
|
|
274
|
+
if body.get("max_tokens") is None:
|
|
275
|
+
body["max_tokens"] = default_max_tokens
|
|
276
|
+
|
|
277
|
+
if thinking_enabled:
|
|
278
|
+
_apply_openrouter_reasoning_policy(body, thinking_cfg)
|
|
279
|
+
|
|
280
|
+
return body
|