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.
Files changed (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. 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
+ )