minimalcode 1.5.2__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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- minimalcode-1.5.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
"""Anthropic Messages payloads, streams, and response parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx2
|
|
10
|
+
|
|
11
|
+
from ..errors import ProviderError
|
|
12
|
+
from .common import (
|
|
13
|
+
content_text,
|
|
14
|
+
decode_json_body,
|
|
15
|
+
is_event_stream,
|
|
16
|
+
stream_event_error,
|
|
17
|
+
strip_sse_bom,
|
|
18
|
+
tool_arguments,
|
|
19
|
+
)
|
|
20
|
+
from .types import ProviderReply, ToolCall
|
|
21
|
+
|
|
22
|
+
# Anthropic requires 1024 <= budget_tokens < max_tokens. The effort names are
|
|
23
|
+
# MinimalCode's, so each one maps to a budget that fits the default 8192-token
|
|
24
|
+
# output cap; a configured max_tokens still wins when it is smaller.
|
|
25
|
+
_THINKING_BUDGETS = {
|
|
26
|
+
"minimal": 1024,
|
|
27
|
+
"low": 2048,
|
|
28
|
+
"medium": 3072,
|
|
29
|
+
"high": 4096,
|
|
30
|
+
"xhigh": 5120,
|
|
31
|
+
"ultra": 6144,
|
|
32
|
+
"max": 7168,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_ANTHROPIC_STOP_REASONS = {
|
|
36
|
+
"end_turn": "stop",
|
|
37
|
+
"stop_sequence": "stop",
|
|
38
|
+
"tool_use": "tool_calls",
|
|
39
|
+
"max_tokens": "max_tokens",
|
|
40
|
+
# A paused server-tool turn cannot be continued by this agent loop.
|
|
41
|
+
"pause_turn": "incomplete",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _anthropic_usage(raw: Any) -> dict[str, int]:
|
|
46
|
+
"""Normalize Anthropic usage, counting cached input as prompt tokens."""
|
|
47
|
+
if not isinstance(raw, dict):
|
|
48
|
+
return {}
|
|
49
|
+
if not any(
|
|
50
|
+
key in raw
|
|
51
|
+
for key in (
|
|
52
|
+
"input_tokens",
|
|
53
|
+
"output_tokens",
|
|
54
|
+
"cache_creation_input_tokens",
|
|
55
|
+
"cache_read_input_tokens",
|
|
56
|
+
)
|
|
57
|
+
):
|
|
58
|
+
# An empty usage object means "not reported", not "a free turn"; the
|
|
59
|
+
# agent uses a missing dict to decide whether to estimate.
|
|
60
|
+
return {}
|
|
61
|
+
try:
|
|
62
|
+
input_tokens = int(raw.get("input_tokens") or 0)
|
|
63
|
+
cache_creation = int(raw.get("cache_creation_input_tokens") or 0)
|
|
64
|
+
cache_read = int(raw.get("cache_read_input_tokens") or 0)
|
|
65
|
+
output_tokens = int(raw.get("output_tokens") or 0)
|
|
66
|
+
except (TypeError, ValueError, OverflowError):
|
|
67
|
+
# Usage is diagnostic; a malformed object must not fail a turn.
|
|
68
|
+
return {}
|
|
69
|
+
prompt_tokens = input_tokens + cache_creation + cache_read
|
|
70
|
+
return {
|
|
71
|
+
"prompt_tokens": prompt_tokens,
|
|
72
|
+
"completion_tokens": output_tokens,
|
|
73
|
+
"total_tokens": prompt_tokens + output_tokens,
|
|
74
|
+
"cached_tokens": cache_read,
|
|
75
|
+
"reasoning_tokens": 0,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AnthropicProtocolMixin:
|
|
80
|
+
"""Anthropic Messages wire format for the provider client."""
|
|
81
|
+
|
|
82
|
+
# Attributes supplied by OpenAICompatibleProvider.
|
|
83
|
+
model: str
|
|
84
|
+
thinking: str
|
|
85
|
+
stream_enabled: bool
|
|
86
|
+
send_reasoning_effort: bool
|
|
87
|
+
max_tokens: int
|
|
88
|
+
config: dict[str, Any]
|
|
89
|
+
|
|
90
|
+
def _open(self, payload: dict[str, Any]) -> httpx2.Response:
|
|
91
|
+
raise NotImplementedError
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _anthropic_finish(stop_reason: Any, has_calls: bool) -> str:
|
|
95
|
+
"""Map an Anthropic stop reason to the agent loop's vocabulary."""
|
|
96
|
+
if stop_reason:
|
|
97
|
+
value = str(stop_reason)
|
|
98
|
+
return _ANTHROPIC_STOP_REASONS.get(value, value)
|
|
99
|
+
return "tool_calls" if has_calls else "stop"
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _anthropic_image_block(value: Any) -> dict[str, Any] | None:
|
|
103
|
+
"""Convert an OpenAI-style image URL into an Anthropic image source."""
|
|
104
|
+
url = value
|
|
105
|
+
if isinstance(value, dict):
|
|
106
|
+
url = value.get("url")
|
|
107
|
+
if not isinstance(url, str) or not url:
|
|
108
|
+
return None
|
|
109
|
+
if url.startswith("data:"):
|
|
110
|
+
header, _, data = url.partition(",")
|
|
111
|
+
media_type = header[5:].split(";", 1)[0]
|
|
112
|
+
if not media_type or not data:
|
|
113
|
+
return None
|
|
114
|
+
return {
|
|
115
|
+
"type": "image",
|
|
116
|
+
"source": {
|
|
117
|
+
"type": "base64",
|
|
118
|
+
"media_type": media_type,
|
|
119
|
+
"data": data,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
return {"type": "image", "source": {"type": "url", "url": url}}
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def _anthropic_content_blocks(cls, content: Any) -> list[dict[str, Any]]:
|
|
126
|
+
"""Flatten stored string or OpenAI-style content into content blocks."""
|
|
127
|
+
if isinstance(content, str):
|
|
128
|
+
return [{"type": "text", "text": content}] if content else []
|
|
129
|
+
if isinstance(content, list):
|
|
130
|
+
blocks: list[dict[str, Any]] = []
|
|
131
|
+
for part in content:
|
|
132
|
+
if isinstance(part, str):
|
|
133
|
+
if part:
|
|
134
|
+
blocks.append({"type": "text", "text": part})
|
|
135
|
+
continue
|
|
136
|
+
if not isinstance(part, dict):
|
|
137
|
+
continue
|
|
138
|
+
part_type = part.get("type")
|
|
139
|
+
if part_type == "text" and isinstance(part.get("text"), str):
|
|
140
|
+
if part["text"]:
|
|
141
|
+
blocks.append({"type": "text", "text": part["text"]})
|
|
142
|
+
elif part_type == "image_url":
|
|
143
|
+
block = cls._anthropic_image_block(part.get("image_url"))
|
|
144
|
+
if block is not None:
|
|
145
|
+
blocks.append(block)
|
|
146
|
+
elif part_type == "image" and isinstance(part.get("source"), dict):
|
|
147
|
+
blocks.append({"type": "image", "source": part["source"]})
|
|
148
|
+
else:
|
|
149
|
+
text = content_text(part)
|
|
150
|
+
if text:
|
|
151
|
+
blocks.append({"type": "text", "text": text})
|
|
152
|
+
return blocks
|
|
153
|
+
if content is None:
|
|
154
|
+
return []
|
|
155
|
+
text = content_text(content)
|
|
156
|
+
return [{"type": "text", "text": text}] if text else []
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def _anthropic_tool_input(cls, arguments: Any) -> dict[str, Any]:
|
|
160
|
+
"""Parse stored tool arguments into the object Anthropic expects."""
|
|
161
|
+
if isinstance(arguments, dict):
|
|
162
|
+
return arguments
|
|
163
|
+
if isinstance(arguments, str):
|
|
164
|
+
try:
|
|
165
|
+
parsed = json.loads(arguments or "{}")
|
|
166
|
+
except json.JSONDecodeError:
|
|
167
|
+
return {}
|
|
168
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
169
|
+
return {}
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def _anthropic_assistant_blocks(
|
|
173
|
+
cls,
|
|
174
|
+
message: dict[str, Any],
|
|
175
|
+
) -> list[dict[str, Any]]:
|
|
176
|
+
"""Build an assistant content list, preferring preserved raw blocks."""
|
|
177
|
+
raw_blocks = message.get("provider_content")
|
|
178
|
+
raw_calls = message.get("tool_calls")
|
|
179
|
+
if isinstance(raw_blocks, list):
|
|
180
|
+
preserved = [
|
|
181
|
+
dict(block)
|
|
182
|
+
for block in raw_blocks
|
|
183
|
+
if isinstance(block, dict)
|
|
184
|
+
and isinstance(block.get("type"), str)
|
|
185
|
+
and block["type"]
|
|
186
|
+
in {"text", "thinking", "redacted_thinking", "tool_use"}
|
|
187
|
+
]
|
|
188
|
+
# Reasoning is only replayed verbatim when every call it belongs to
|
|
189
|
+
# is still there; a partial block list would orphan a tool_result.
|
|
190
|
+
expected = {
|
|
191
|
+
str(call.get("id") or "")
|
|
192
|
+
for call in raw_calls or []
|
|
193
|
+
if isinstance(call, dict)
|
|
194
|
+
}
|
|
195
|
+
replayed = {
|
|
196
|
+
str(block.get("id") or "")
|
|
197
|
+
for block in preserved
|
|
198
|
+
if block.get("type") == "tool_use"
|
|
199
|
+
}
|
|
200
|
+
if preserved and expected <= replayed:
|
|
201
|
+
return preserved
|
|
202
|
+
|
|
203
|
+
blocks = cls._anthropic_content_blocks(message.get("content"))
|
|
204
|
+
if not isinstance(raw_calls, list):
|
|
205
|
+
return blocks
|
|
206
|
+
for index, call in enumerate(raw_calls):
|
|
207
|
+
if not isinstance(call, dict):
|
|
208
|
+
continue
|
|
209
|
+
function = call.get("function")
|
|
210
|
+
if not isinstance(function, dict):
|
|
211
|
+
continue
|
|
212
|
+
name = str(function.get("name") or "")
|
|
213
|
+
if not name:
|
|
214
|
+
continue
|
|
215
|
+
blocks.append(
|
|
216
|
+
{
|
|
217
|
+
"type": "tool_use",
|
|
218
|
+
"id": str(call.get("id") or f"toolu_{index}"),
|
|
219
|
+
"name": name,
|
|
220
|
+
"input": cls._anthropic_tool_input(function.get("arguments")),
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
return blocks
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _merge_user_blocks(
|
|
227
|
+
existing: list[dict[str, Any]],
|
|
228
|
+
added: list[dict[str, Any]],
|
|
229
|
+
) -> list[dict[str, Any]]:
|
|
230
|
+
"""Merge user blocks with every tool_result ahead of text."""
|
|
231
|
+
merged = [*existing, *added]
|
|
232
|
+
results = [block for block in merged if block.get("type") == "tool_result"]
|
|
233
|
+
others = [block for block in merged if block.get("type") != "tool_result"]
|
|
234
|
+
return [*results, *others]
|
|
235
|
+
|
|
236
|
+
def _anthropic_messages(
|
|
237
|
+
self,
|
|
238
|
+
messages: list[dict[str, Any]],
|
|
239
|
+
) -> tuple[list[str], list[dict[str, Any]]]:
|
|
240
|
+
"""Split system text and convert stored history into Messages turns."""
|
|
241
|
+
system_parts: list[str] = []
|
|
242
|
+
converted: list[dict[str, Any]] = []
|
|
243
|
+
|
|
244
|
+
for message in messages:
|
|
245
|
+
if not isinstance(message, dict):
|
|
246
|
+
continue
|
|
247
|
+
role = message.get("role")
|
|
248
|
+
if not isinstance(role, str):
|
|
249
|
+
# Session history is a user-editable file; a non-string role
|
|
250
|
+
# cannot be sent and must not raise TypeError from the set
|
|
251
|
+
# membership test below.
|
|
252
|
+
continue
|
|
253
|
+
if role in {"system", "developer"}:
|
|
254
|
+
text = content_text(message.get("content"))
|
|
255
|
+
if text:
|
|
256
|
+
system_parts.append(text)
|
|
257
|
+
continue
|
|
258
|
+
|
|
259
|
+
if role == "tool":
|
|
260
|
+
content = message.get("content")
|
|
261
|
+
blocks = self._anthropic_content_blocks(content)
|
|
262
|
+
tool_result: dict[str, Any] = {
|
|
263
|
+
"type": "tool_result",
|
|
264
|
+
"tool_use_id": str(message.get("tool_call_id") or ""),
|
|
265
|
+
"content": blocks or "",
|
|
266
|
+
}
|
|
267
|
+
if isinstance(content, str):
|
|
268
|
+
try:
|
|
269
|
+
parsed = json.loads(content)
|
|
270
|
+
except json.JSONDecodeError:
|
|
271
|
+
parsed = None
|
|
272
|
+
if isinstance(parsed, dict) and parsed.get("status") == "error":
|
|
273
|
+
tool_result["is_error"] = True
|
|
274
|
+
if converted and converted[-1].get("role") == "user":
|
|
275
|
+
converted[-1]["content"] = self._merge_user_blocks(
|
|
276
|
+
converted[-1]["content"], [tool_result]
|
|
277
|
+
)
|
|
278
|
+
else:
|
|
279
|
+
converted.append({"role": "user", "content": [tool_result]})
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
if role == "assistant":
|
|
283
|
+
blocks = self._anthropic_assistant_blocks(message)
|
|
284
|
+
elif role == "user":
|
|
285
|
+
blocks = self._anthropic_content_blocks(message.get("content"))
|
|
286
|
+
else:
|
|
287
|
+
continue
|
|
288
|
+
if not blocks:
|
|
289
|
+
continue
|
|
290
|
+
if converted and converted[-1].get("role") == role:
|
|
291
|
+
if role == "user":
|
|
292
|
+
converted[-1]["content"] = self._merge_user_blocks(
|
|
293
|
+
converted[-1]["content"], blocks
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
converted[-1]["content"].extend(blocks)
|
|
297
|
+
else:
|
|
298
|
+
converted.append({"role": role, "content": blocks})
|
|
299
|
+
|
|
300
|
+
return system_parts, converted
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def _anthropic_tools(
|
|
304
|
+
tools: list[dict[str, Any]] | None,
|
|
305
|
+
) -> list[dict[str, Any]] | None:
|
|
306
|
+
"""Convert Chat-style function tools into Anthropic tool definitions."""
|
|
307
|
+
if not tools:
|
|
308
|
+
return None
|
|
309
|
+
result: list[dict[str, Any]] = []
|
|
310
|
+
for tool in tools:
|
|
311
|
+
function = tool.get("function") if isinstance(tool, dict) else None
|
|
312
|
+
if not isinstance(function, dict):
|
|
313
|
+
continue
|
|
314
|
+
name = str(function.get("name") or "")
|
|
315
|
+
if not name:
|
|
316
|
+
continue
|
|
317
|
+
definition: dict[str, Any] = {
|
|
318
|
+
"name": name,
|
|
319
|
+
"input_schema": function.get("parameters")
|
|
320
|
+
if isinstance(function.get("parameters"), dict)
|
|
321
|
+
else {"type": "object"},
|
|
322
|
+
}
|
|
323
|
+
description = function.get("description")
|
|
324
|
+
if isinstance(description, str) and description:
|
|
325
|
+
definition["description"] = description
|
|
326
|
+
result.append(definition)
|
|
327
|
+
return result or None
|
|
328
|
+
|
|
329
|
+
def _anthropic_thinking(self) -> dict[str, Any] | None:
|
|
330
|
+
"""Translate the configured effort into an Anthropic thinking object."""
|
|
331
|
+
effort = self.thinking.strip().lower()
|
|
332
|
+
if effort == "none":
|
|
333
|
+
return {"type": "disabled"}
|
|
334
|
+
if not effort:
|
|
335
|
+
return None
|
|
336
|
+
if self.max_tokens <= 1024:
|
|
337
|
+
raise ProviderError(
|
|
338
|
+
"Anthropic thinking requires max_tokens greater than 1024"
|
|
339
|
+
)
|
|
340
|
+
budget = _THINKING_BUDGETS.get(effort, 4096)
|
|
341
|
+
budget = max(1024, min(budget, self.max_tokens - 1))
|
|
342
|
+
return {"type": "enabled", "budget_tokens": budget}
|
|
343
|
+
|
|
344
|
+
def _anthropic_prompt_cache(self) -> bool:
|
|
345
|
+
"""Return True when this provider opts into Anthropic prompt caching.
|
|
346
|
+
|
|
347
|
+
The block is opt-in: a gateway that speaks the Messages protocol but
|
|
348
|
+
rejects ``cache_control`` would otherwise fail every request, and the
|
|
349
|
+
field is only useful on Claude models that price cache reads lower.
|
|
350
|
+
"""
|
|
351
|
+
return bool(self.config.get("prompt_cache"))
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def _anthropic_system(
|
|
355
|
+
system_parts: list[str], *, cache: bool
|
|
356
|
+
) -> str | list[dict[str, Any]]:
|
|
357
|
+
"""Join the system prompt, optionally as one cacheable text block."""
|
|
358
|
+
text = "\n\n".join(system_parts)
|
|
359
|
+
if not cache:
|
|
360
|
+
return text
|
|
361
|
+
return [{"type": "text", "text": text, "cache_control": {"type": "ephemeral"}}]
|
|
362
|
+
|
|
363
|
+
def _anthropic_payload(
|
|
364
|
+
self,
|
|
365
|
+
messages: list[dict[str, Any]],
|
|
366
|
+
tools: list[dict[str, Any]] | None,
|
|
367
|
+
) -> dict[str, Any]:
|
|
368
|
+
"""Build the Anthropic Messages request body."""
|
|
369
|
+
system_parts, converted = self._anthropic_messages(messages)
|
|
370
|
+
if not converted:
|
|
371
|
+
raise ProviderError("provider request requires at least one message")
|
|
372
|
+
payload: dict[str, Any] = {
|
|
373
|
+
"model": self.model,
|
|
374
|
+
"max_tokens": self.max_tokens,
|
|
375
|
+
"messages": converted,
|
|
376
|
+
"stream": self.stream_enabled,
|
|
377
|
+
}
|
|
378
|
+
cache = self._anthropic_prompt_cache()
|
|
379
|
+
converted_tools = self._anthropic_tools(tools)
|
|
380
|
+
# One breakpoint covers the whole static prefix. Anthropic caches the
|
|
381
|
+
# request up to the last marked block, so marking the last tool caches
|
|
382
|
+
# the tool list and the system text together; with no tools the system
|
|
383
|
+
# block is the prefix. Marking both would write two cache entries for
|
|
384
|
+
# the same bytes on every turn.
|
|
385
|
+
if system_parts:
|
|
386
|
+
payload["system"] = self._anthropic_system(
|
|
387
|
+
system_parts, cache=cache and not converted_tools
|
|
388
|
+
)
|
|
389
|
+
if converted_tools:
|
|
390
|
+
if cache:
|
|
391
|
+
converted_tools[-1]["cache_control"] = {"type": "ephemeral"}
|
|
392
|
+
payload["tools"] = converted_tools
|
|
393
|
+
if self.send_reasoning_effort:
|
|
394
|
+
thinking = self._anthropic_thinking()
|
|
395
|
+
if thinking is not None:
|
|
396
|
+
payload["thinking"] = thinking
|
|
397
|
+
extra = self.config.get("extra_body")
|
|
398
|
+
if isinstance(extra, dict):
|
|
399
|
+
# Never let extra_body override protocol-critical fields.
|
|
400
|
+
reserved = {
|
|
401
|
+
"model",
|
|
402
|
+
"max_tokens",
|
|
403
|
+
"messages",
|
|
404
|
+
"system",
|
|
405
|
+
"stream",
|
|
406
|
+
"tools",
|
|
407
|
+
"tool_choice",
|
|
408
|
+
"thinking",
|
|
409
|
+
}
|
|
410
|
+
payload.update(
|
|
411
|
+
{key: value for key, value in extra.items() if key not in reserved}
|
|
412
|
+
)
|
|
413
|
+
return payload
|
|
414
|
+
|
|
415
|
+
def _anthropic_stream_request(
|
|
416
|
+
self,
|
|
417
|
+
payload: dict[str, Any],
|
|
418
|
+
*,
|
|
419
|
+
on_text: Callable[[str], None] | None,
|
|
420
|
+
on_thinking: Callable[[str], None] | None,
|
|
421
|
+
) -> ProviderReply:
|
|
422
|
+
"""Request a streaming reply, tolerating JSON when SSE is misconfigured."""
|
|
423
|
+
response = self._open(payload)
|
|
424
|
+
try:
|
|
425
|
+
if not is_event_stream(response):
|
|
426
|
+
body = self._read_body(response)
|
|
427
|
+
parsed, data = decode_json_body(body)
|
|
428
|
+
if parsed:
|
|
429
|
+
return self._parse_anthropic_payload(
|
|
430
|
+
data, on_text=on_text, on_thinking=on_thinking
|
|
431
|
+
)
|
|
432
|
+
text = body.decode("utf-8", errors="replace")
|
|
433
|
+
if "data:" not in text:
|
|
434
|
+
raise ProviderError("provider returned an invalid response")
|
|
435
|
+
lines: Iterable[str] = text.splitlines()
|
|
436
|
+
else:
|
|
437
|
+
lines = response.iter_lines()
|
|
438
|
+
return self._parse_anthropic_stream(
|
|
439
|
+
lines, on_text=on_text, on_thinking=on_thinking
|
|
440
|
+
)
|
|
441
|
+
finally:
|
|
442
|
+
response.close()
|
|
443
|
+
|
|
444
|
+
@staticmethod
|
|
445
|
+
def _event_index(event: dict[str, Any]) -> int:
|
|
446
|
+
try:
|
|
447
|
+
return int(event.get("index", -1))
|
|
448
|
+
except (TypeError, ValueError):
|
|
449
|
+
return -1
|
|
450
|
+
|
|
451
|
+
def _parse_anthropic_stream(
|
|
452
|
+
self,
|
|
453
|
+
lines: Iterable[str],
|
|
454
|
+
*,
|
|
455
|
+
on_text: Callable[[str], None] | None,
|
|
456
|
+
on_thinking: Callable[[str], None] | None,
|
|
457
|
+
) -> ProviderReply:
|
|
458
|
+
"""Accumulate Anthropic SSE events into one normalized reply."""
|
|
459
|
+
content_parts: list[str] = []
|
|
460
|
+
# Content arrives under an index: text and thinking deltas append to the
|
|
461
|
+
# open block, while tool input arrives as a partial JSON string.
|
|
462
|
+
blocks: dict[int, dict[str, Any]] = {}
|
|
463
|
+
stop_reason: Any = None
|
|
464
|
+
saw_message_stop = False
|
|
465
|
+
usage_raw: dict[str, Any] = {}
|
|
466
|
+
|
|
467
|
+
def state_for(index: int, block_type: str) -> dict[str, Any]:
|
|
468
|
+
state = blocks.get(index)
|
|
469
|
+
if state is None:
|
|
470
|
+
state = {
|
|
471
|
+
"type": block_type,
|
|
472
|
+
"text": "",
|
|
473
|
+
"thinking": "",
|
|
474
|
+
"signature": "",
|
|
475
|
+
"id": "",
|
|
476
|
+
"name": "",
|
|
477
|
+
"input": {},
|
|
478
|
+
"partial_json": "",
|
|
479
|
+
"data": "",
|
|
480
|
+
}
|
|
481
|
+
blocks[index] = state
|
|
482
|
+
return state
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
for data in self._sse_events(lines):
|
|
486
|
+
# The assembler strips a line-leading BOM; a payload that
|
|
487
|
+
# carries one after `data: ` is stripped here.
|
|
488
|
+
data = strip_sse_bom(data).strip()
|
|
489
|
+
if not data:
|
|
490
|
+
continue
|
|
491
|
+
if data == "[DONE]":
|
|
492
|
+
# Some Anthropic-compatible gateways end the stream with the
|
|
493
|
+
# OpenAI sentinel instead of `message_stop`. Reading that as
|
|
494
|
+
# a truncation reported a finished answer as incomplete and
|
|
495
|
+
# threw away its tool calls. It only counts as a clean end
|
|
496
|
+
# once the stop reason has actually been reported.
|
|
497
|
+
if stop_reason is not None:
|
|
498
|
+
saw_message_stop = True
|
|
499
|
+
break
|
|
500
|
+
try:
|
|
501
|
+
event = json.loads(data)
|
|
502
|
+
except json.JSONDecodeError:
|
|
503
|
+
continue
|
|
504
|
+
if not isinstance(event, dict):
|
|
505
|
+
continue
|
|
506
|
+
event_type = str(event.get("type") or "")
|
|
507
|
+
if event_type == "error" or (not event_type and event.get("error")):
|
|
508
|
+
raise stream_event_error(event)
|
|
509
|
+
|
|
510
|
+
if event_type == "message_start":
|
|
511
|
+
message = event.get("message")
|
|
512
|
+
if isinstance(message, dict) and isinstance(
|
|
513
|
+
message.get("usage"), dict
|
|
514
|
+
):
|
|
515
|
+
usage_raw.update(message["usage"])
|
|
516
|
+
continue
|
|
517
|
+
if event_type == "content_block_start":
|
|
518
|
+
index = self._event_index(event)
|
|
519
|
+
block = event.get("content_block")
|
|
520
|
+
if index < 0 or not isinstance(block, dict):
|
|
521
|
+
continue
|
|
522
|
+
block_type = str(block.get("type") or "")
|
|
523
|
+
if block_type not in {
|
|
524
|
+
"text",
|
|
525
|
+
"thinking",
|
|
526
|
+
"redacted_thinking",
|
|
527
|
+
"tool_use",
|
|
528
|
+
}:
|
|
529
|
+
# Server tools are executed provider-side and cannot be
|
|
530
|
+
# replayed as client content in the next request.
|
|
531
|
+
continue
|
|
532
|
+
state = state_for(index, block_type)
|
|
533
|
+
state["type"] = block_type
|
|
534
|
+
if block_type == "redacted_thinking":
|
|
535
|
+
# Anthropic requires this opaque block to be replayed
|
|
536
|
+
# verbatim while a tool call is open.
|
|
537
|
+
data = block.get("data")
|
|
538
|
+
if isinstance(data, str):
|
|
539
|
+
state["data"] += data
|
|
540
|
+
elif block_type == "text":
|
|
541
|
+
text = content_text(block.get("text"))
|
|
542
|
+
if text:
|
|
543
|
+
state["text"] += text
|
|
544
|
+
content_parts.append(text)
|
|
545
|
+
if on_text is not None:
|
|
546
|
+
on_text(text)
|
|
547
|
+
elif block_type == "thinking":
|
|
548
|
+
thinking = content_text(block.get("thinking"))
|
|
549
|
+
if thinking:
|
|
550
|
+
state["thinking"] += thinking
|
|
551
|
+
if on_thinking is not None:
|
|
552
|
+
on_thinking(thinking)
|
|
553
|
+
if isinstance(block.get("signature"), str):
|
|
554
|
+
state["signature"] += block["signature"]
|
|
555
|
+
else:
|
|
556
|
+
state["id"] = str(block.get("id") or "")
|
|
557
|
+
state["name"] = str(block.get("name") or "")
|
|
558
|
+
if isinstance(block.get("input"), dict):
|
|
559
|
+
state["input"] = block["input"]
|
|
560
|
+
continue
|
|
561
|
+
if event_type == "content_block_delta":
|
|
562
|
+
index = self._event_index(event)
|
|
563
|
+
delta = event.get("delta")
|
|
564
|
+
if index < 0 or not isinstance(delta, dict):
|
|
565
|
+
continue
|
|
566
|
+
delta_type = str(delta.get("type") or "")
|
|
567
|
+
if delta_type == "text_delta":
|
|
568
|
+
text = content_text(delta.get("text"))
|
|
569
|
+
if text:
|
|
570
|
+
state = state_for(index, "text")
|
|
571
|
+
state["text"] += text
|
|
572
|
+
content_parts.append(text)
|
|
573
|
+
if on_text is not None:
|
|
574
|
+
on_text(text)
|
|
575
|
+
elif delta_type == "thinking_delta":
|
|
576
|
+
thinking = content_text(delta.get("thinking"))
|
|
577
|
+
if thinking:
|
|
578
|
+
state = state_for(index, "thinking")
|
|
579
|
+
state["thinking"] += thinking
|
|
580
|
+
if on_thinking is not None:
|
|
581
|
+
on_thinking(thinking)
|
|
582
|
+
elif delta_type == "signature_delta":
|
|
583
|
+
signature = delta.get("signature")
|
|
584
|
+
if isinstance(signature, str):
|
|
585
|
+
state_for(index, "thinking")["signature"] += signature
|
|
586
|
+
elif delta_type == "input_json_delta":
|
|
587
|
+
partial = delta.get("partial_json")
|
|
588
|
+
if isinstance(partial, str):
|
|
589
|
+
state_for(index, "tool_use")["partial_json"] += partial
|
|
590
|
+
continue
|
|
591
|
+
if event_type == "content_block_stop":
|
|
592
|
+
continue
|
|
593
|
+
if event_type == "message_delta":
|
|
594
|
+
delta = event.get("delta")
|
|
595
|
+
if isinstance(delta, dict) and delta.get("stop_reason"):
|
|
596
|
+
stop_reason = delta["stop_reason"]
|
|
597
|
+
# `message_delta.usage` is cumulative for the output count,
|
|
598
|
+
# but a gateway may repeat `input_tokens: 0` here; a zero
|
|
599
|
+
# placeholder must not erase what `message_start` reported.
|
|
600
|
+
usage = event.get("usage")
|
|
601
|
+
if isinstance(usage, dict):
|
|
602
|
+
usage_raw.update(
|
|
603
|
+
{
|
|
604
|
+
key: value
|
|
605
|
+
for key, value in usage.items()
|
|
606
|
+
if value
|
|
607
|
+
or key
|
|
608
|
+
not in (
|
|
609
|
+
"input_tokens",
|
|
610
|
+
"cache_read_input_tokens",
|
|
611
|
+
"cache_creation_input_tokens",
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
)
|
|
615
|
+
continue
|
|
616
|
+
if event_type == "message_stop":
|
|
617
|
+
saw_message_stop = True
|
|
618
|
+
except httpx2.HTTPError as exc:
|
|
619
|
+
raise ProviderError(f"provider stream interrupted: {exc}") from exc
|
|
620
|
+
|
|
621
|
+
tool_calls: list[ToolCall] = []
|
|
622
|
+
provider_content: list[dict[str, Any]] = []
|
|
623
|
+
for index in sorted(blocks):
|
|
624
|
+
state = blocks[index]
|
|
625
|
+
block_type = state["type"]
|
|
626
|
+
if block_type == "text":
|
|
627
|
+
if state["text"]:
|
|
628
|
+
provider_content.append({"type": "text", "text": state["text"]})
|
|
629
|
+
continue
|
|
630
|
+
if block_type == "redacted_thinking":
|
|
631
|
+
if state["data"]:
|
|
632
|
+
provider_content.append(
|
|
633
|
+
{"type": "redacted_thinking", "data": state["data"]}
|
|
634
|
+
)
|
|
635
|
+
continue
|
|
636
|
+
if block_type == "thinking":
|
|
637
|
+
if not state["thinking"] and not state["signature"]:
|
|
638
|
+
continue
|
|
639
|
+
block: dict[str, Any] = {
|
|
640
|
+
"type": "thinking",
|
|
641
|
+
"thinking": state["thinking"],
|
|
642
|
+
}
|
|
643
|
+
if state["signature"]:
|
|
644
|
+
block["signature"] = state["signature"]
|
|
645
|
+
provider_content.append(block)
|
|
646
|
+
continue
|
|
647
|
+
if block_type != "tool_use" or not state["name"]:
|
|
648
|
+
continue
|
|
649
|
+
tool_input = state["input"]
|
|
650
|
+
arguments_text = tool_arguments(tool_input)
|
|
651
|
+
if state["partial_json"]:
|
|
652
|
+
try:
|
|
653
|
+
parsed = json.loads(state["partial_json"])
|
|
654
|
+
except json.JSONDecodeError:
|
|
655
|
+
# Keep the malformed fragment so the tool layer reports an
|
|
656
|
+
# invalid-JSON result instead of executing empty arguments.
|
|
657
|
+
arguments_text = state["partial_json"]
|
|
658
|
+
else:
|
|
659
|
+
if isinstance(parsed, dict):
|
|
660
|
+
tool_input = parsed
|
|
661
|
+
arguments_text = tool_arguments(tool_input)
|
|
662
|
+
else:
|
|
663
|
+
arguments_text = state["partial_json"]
|
|
664
|
+
if not isinstance(tool_input, dict):
|
|
665
|
+
tool_input = {}
|
|
666
|
+
call_id = state["id"] or f"toolu_{index}"
|
|
667
|
+
tool_calls.append(
|
|
668
|
+
ToolCall(
|
|
669
|
+
id=call_id,
|
|
670
|
+
name=state["name"],
|
|
671
|
+
arguments=arguments_text,
|
|
672
|
+
)
|
|
673
|
+
)
|
|
674
|
+
provider_content.append(
|
|
675
|
+
{
|
|
676
|
+
"type": "tool_use",
|
|
677
|
+
"id": call_id,
|
|
678
|
+
"name": state["name"],
|
|
679
|
+
"input": tool_input,
|
|
680
|
+
}
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
if not saw_message_stop:
|
|
684
|
+
# Anthropic closes an interrupted response without message_stop;
|
|
685
|
+
# reporting a clean stop could execute a half-received tool call.
|
|
686
|
+
finish_reason = "incomplete"
|
|
687
|
+
else:
|
|
688
|
+
finish_reason = self._anthropic_finish(stop_reason, bool(tool_calls))
|
|
689
|
+
if not content_parts and not tool_calls and not saw_message_stop:
|
|
690
|
+
raise ProviderError("provider returned an empty stream")
|
|
691
|
+
return ProviderReply(
|
|
692
|
+
content="".join(content_parts),
|
|
693
|
+
tool_calls=tool_calls,
|
|
694
|
+
finish_reason=finish_reason,
|
|
695
|
+
usage=_anthropic_usage(usage_raw),
|
|
696
|
+
# Thinking signatures are only replayed while a tool call is open;
|
|
697
|
+
# a finished answer does not need them in the session history.
|
|
698
|
+
provider_content=provider_content if tool_calls else None,
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
def _parse_anthropic_payload(
|
|
702
|
+
self,
|
|
703
|
+
data: Any,
|
|
704
|
+
*,
|
|
705
|
+
on_text: Callable[[str], None] | None,
|
|
706
|
+
on_thinking: Callable[[str], None] | None,
|
|
707
|
+
) -> ProviderReply:
|
|
708
|
+
"""Parse one non-streaming Anthropic Messages response body."""
|
|
709
|
+
if not isinstance(data, dict):
|
|
710
|
+
raise ProviderError("provider response must be an object")
|
|
711
|
+
if data.get("type") == "error" or data.get("error"):
|
|
712
|
+
detail = data.get("error") or "unknown error"
|
|
713
|
+
raise ProviderError(f"provider response failed: {detail}")
|
|
714
|
+
blocks = data.get("content")
|
|
715
|
+
if not isinstance(blocks, list):
|
|
716
|
+
raise ProviderError("provider response has no content")
|
|
717
|
+
|
|
718
|
+
content_parts: list[str] = []
|
|
719
|
+
tool_calls: list[ToolCall] = []
|
|
720
|
+
provider_content: list[dict[str, Any]] = []
|
|
721
|
+
for block in blocks:
|
|
722
|
+
if not isinstance(block, dict):
|
|
723
|
+
continue
|
|
724
|
+
block_type = block.get("type")
|
|
725
|
+
if block_type == "text":
|
|
726
|
+
text = content_text(block.get("text"))
|
|
727
|
+
if text:
|
|
728
|
+
content_parts.append(text)
|
|
729
|
+
provider_content.append({"type": "text", "text": text})
|
|
730
|
+
if on_text is not None:
|
|
731
|
+
on_text(text)
|
|
732
|
+
elif block_type == "redacted_thinking":
|
|
733
|
+
# Named apart from the response body: rebinding ``data`` here
|
|
734
|
+
# used to break the stop-reason lookup below with AttributeError.
|
|
735
|
+
redacted = block.get("data")
|
|
736
|
+
if isinstance(redacted, str) and redacted:
|
|
737
|
+
provider_content.append(
|
|
738
|
+
{"type": "redacted_thinking", "data": redacted}
|
|
739
|
+
)
|
|
740
|
+
elif block_type == "thinking":
|
|
741
|
+
thinking = content_text(block.get("thinking"))
|
|
742
|
+
if thinking and on_thinking is not None:
|
|
743
|
+
on_thinking(thinking)
|
|
744
|
+
preserved: dict[str, Any] = {
|
|
745
|
+
"type": "thinking",
|
|
746
|
+
"thinking": thinking,
|
|
747
|
+
}
|
|
748
|
+
if isinstance(block.get("signature"), str) and block["signature"]:
|
|
749
|
+
preserved["signature"] = block["signature"]
|
|
750
|
+
provider_content.append(preserved)
|
|
751
|
+
elif block_type == "tool_use":
|
|
752
|
+
name = str(block.get("name") or "")
|
|
753
|
+
if not name:
|
|
754
|
+
continue
|
|
755
|
+
tool_input = block.get("input")
|
|
756
|
+
if not isinstance(tool_input, dict):
|
|
757
|
+
tool_input = {}
|
|
758
|
+
call_id = str(block.get("id") or f"toolu_{len(tool_calls)}")
|
|
759
|
+
tool_calls.append(
|
|
760
|
+
ToolCall(
|
|
761
|
+
id=call_id,
|
|
762
|
+
name=name,
|
|
763
|
+
arguments=tool_arguments(tool_input),
|
|
764
|
+
)
|
|
765
|
+
)
|
|
766
|
+
provider_content.append(
|
|
767
|
+
{
|
|
768
|
+
"type": "tool_use",
|
|
769
|
+
"id": call_id,
|
|
770
|
+
"name": name,
|
|
771
|
+
"input": tool_input,
|
|
772
|
+
}
|
|
773
|
+
)
|
|
774
|
+
return ProviderReply(
|
|
775
|
+
content="".join(content_parts),
|
|
776
|
+
tool_calls=tool_calls,
|
|
777
|
+
finish_reason=self._anthropic_finish(
|
|
778
|
+
data.get("stop_reason"), bool(tool_calls)
|
|
779
|
+
),
|
|
780
|
+
usage=_anthropic_usage(data.get("usage")),
|
|
781
|
+
provider_content=provider_content if tool_calls else None,
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
def _anthropic_regular_request(
|
|
785
|
+
self,
|
|
786
|
+
payload: dict[str, Any],
|
|
787
|
+
*,
|
|
788
|
+
on_text: Callable[[str], None] | None,
|
|
789
|
+
on_thinking: Callable[[str], None] | None,
|
|
790
|
+
) -> ProviderReply:
|
|
791
|
+
"""Request a non-streaming reply, accepting SSE if the server sends it."""
|
|
792
|
+
response = self._open(payload)
|
|
793
|
+
try:
|
|
794
|
+
if is_event_stream(response):
|
|
795
|
+
return self._parse_anthropic_stream(
|
|
796
|
+
response.iter_lines(), on_text=on_text, on_thinking=on_thinking
|
|
797
|
+
)
|
|
798
|
+
parsed, data = decode_json_body(self._read_body(response))
|
|
799
|
+
if not parsed:
|
|
800
|
+
raise ProviderError("provider returned invalid JSON")
|
|
801
|
+
finally:
|
|
802
|
+
response.close()
|
|
803
|
+
return self._parse_anthropic_payload(
|
|
804
|
+
data, on_text=on_text, on_thinking=on_thinking
|
|
805
|
+
)
|