pi-durable-python 1.3.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.
Files changed (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,603 @@
1
+ """Anthropic Messages model adapter (optional ``anthropic`` extra).
2
+
3
+ The adapter implements the port's :class:`~pi_durable.providers.Models` protocol over the official
4
+ ``AsyncAnthropic`` client. It builds a Messages request from a pi-ai shaped transcript, translates
5
+ streamed events into the port's assistant-message events (text, signed thinking, and tool-call
6
+ deltas), and returns the terminal message generation and compaction classify.
7
+
8
+ The model catalog belongs to the caller: pass a sequence of :class:`~pi_durable.providers.Model`
9
+ values and the adapter resolves ``(provider, model_id)`` through it, so context windows, max-token
10
+ ceilings, input capabilities, and cost rates travel with each model. Anthropic does not take a
11
+ session ID; the conversation's ``pi.provider`` identity is used by providers that have a cache key.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import os
18
+ import re
19
+ from collections.abc import Mapping, Sequence
20
+ from typing import Any, cast
21
+
22
+ from . import Model
23
+ from .base import ProviderModels
24
+ from .json_parse import parse_streaming_json, parse_tool_call_arguments
25
+ from .request import optional_int, request_options
26
+ from .retry import retry_provider_request
27
+ from .stream import ProviderStream, content_index, now_ms, provider_field, provider_mapping
28
+ from .transcript import collapse_system_messages, system_message_text, transform_messages
29
+ from .usage import calculate_cost, zero_usage
30
+
31
+ ANTHROPIC_API = "anthropic-messages"
32
+ DEFAULT_MAX_TOKENS = 8_192
33
+
34
+ _THINKING_BUDGETS = {
35
+ "minimal": 1_024,
36
+ "low": 2_048,
37
+ "medium": 8_192,
38
+ "high": 16_384,
39
+ "xhigh": 16_384,
40
+ "max": 16_384,
41
+ }
42
+ _MIN_ANSWER_TOKENS = 1_024
43
+ _TOOL_CALL_ID = re.compile(r"[^a-zA-Z0-9_-]")
44
+
45
+ _STOP_REASONS = {
46
+ "end_turn": "stop",
47
+ "max_tokens": "length",
48
+ "tool_use": "toolUse",
49
+ "pause_turn": "stop",
50
+ "stop_sequence": "stop",
51
+ }
52
+
53
+
54
+ class AnthropicModels(ProviderModels):
55
+ """The ``Models`` protocol over Anthropic Messages.
56
+
57
+ ``client`` injects an ``AsyncAnthropic``-shaped client (tests use a fake); otherwise a client is
58
+ built lazily from ``api_key``, ``base_url``, and ``headers``. Without an injected client an API
59
+ key is required in the same call, ``ANTHROPIC_API_KEY``, or ``options["apiKey"]``.
60
+ """
61
+
62
+ api = ANTHROPIC_API
63
+
64
+ def __init__(
65
+ self,
66
+ models: Sequence[Model] = (),
67
+ *,
68
+ client: Any | None = None,
69
+ api_key: str | None = None,
70
+ base_url: str | None = None,
71
+ headers: Mapping[str, str] | None = None,
72
+ ) -> None:
73
+ super().__init__(models)
74
+ self._client = client
75
+ self._api_key = api_key
76
+ self._base_url = base_url
77
+ self._headers = dict(headers or {})
78
+
79
+ # ── Models protocol ───────────────────────────────────────────────────
80
+
81
+ def stream_simple(
82
+ self,
83
+ model: Model,
84
+ context: Mapping[str, Any],
85
+ options: Mapping[str, Any] | None = None,
86
+ ) -> ProviderStream:
87
+ return self._stream(model, context, options or {}, simple=True)
88
+
89
+ def stream(
90
+ self,
91
+ model: Model,
92
+ context: Mapping[str, Any],
93
+ options: Mapping[str, Any] | None = None,
94
+ ) -> ProviderStream:
95
+ return self._stream(model, context, options or {}, simple=False)
96
+
97
+ # ── Request assembly ──────────────────────────────────────────────────
98
+
99
+ def _stream(
100
+ self,
101
+ model: Model,
102
+ context: Mapping[str, Any],
103
+ options: Mapping[str, Any],
104
+ *,
105
+ simple: bool,
106
+ ) -> ProviderStream:
107
+ client = self._resolve_client(model, options)
108
+ output = _output_message(model)
109
+ return _AnthropicStream(client, model, context, options, output, simple=simple)
110
+
111
+ def _resolve_client(self, model: Model, options: Mapping[str, Any]) -> Any:
112
+ if self._client is not None:
113
+ return self._client
114
+ api_key = (
115
+ self._api_key
116
+ or cast(str | None, options.get("apiKey"))
117
+ or os.environ.get("ANTHROPIC_API_KEY")
118
+ )
119
+ if not api_key:
120
+ raise ValueError(f"No API key for provider: {model.provider}")
121
+ try:
122
+ from anthropic import AsyncAnthropic
123
+ except ImportError as error: # pragma: no cover - covered by the extras test
124
+ raise ImportError(
125
+ "The Anthropic adapter requires the 'anthropic' extra: "
126
+ "pip install pi-durable-python[anthropic]"
127
+ ) from error
128
+ self._client = AsyncAnthropic(
129
+ api_key=api_key,
130
+ base_url=self._base_url,
131
+ default_headers=dict(self._headers) or None,
132
+ max_retries=0,
133
+ )
134
+ return self._client
135
+
136
+
137
+ def _output_message(model: Model) -> dict[str, Any]:
138
+ return {
139
+ "role": "assistant",
140
+ "content": [],
141
+ "api": ANTHROPIC_API,
142
+ "provider": model.provider,
143
+ "model": model.model_id,
144
+ "usage": zero_usage(),
145
+ "stopReason": "pending",
146
+ "timestamp": now_ms(),
147
+ }
148
+
149
+
150
+ class _AnthropicStream(ProviderStream):
151
+ """One Messages request translated into assistant-message events."""
152
+
153
+ def __init__(
154
+ self,
155
+ client: Any,
156
+ model: Model,
157
+ context: Mapping[str, Any],
158
+ options: Mapping[str, Any],
159
+ output: dict[str, Any],
160
+ *,
161
+ simple: bool,
162
+ ) -> None:
163
+ self._client = client
164
+ self._model = model
165
+ self._context = context
166
+ self._options = options
167
+ self._simple = simple
168
+ self._blocks: dict[int, dict[str, Any]] = {}
169
+ self._tool_text: dict[int, str] = {}
170
+ super().__init__(output, signal=cast(Any, options.get("signal")))
171
+
172
+ async def _produce(self) -> None:
173
+ params = build_params(self._model, self._context, self._options, simple=self._simple)
174
+ sdk_options = request_options(self._options)
175
+ create = self._client.messages.create
176
+
177
+ async def call() -> Any:
178
+ return await create(**params, **sdk_options)
179
+
180
+ stream = await retry_provider_request(
181
+ call,
182
+ max_retries=int(self._options.get("maxRetries") or 0),
183
+ max_retry_delay_ms=optional_int(self._options.get("maxRetryDelayMs")),
184
+ signal=cast(Any, self._options.get("signal")),
185
+ )
186
+ self.emit("start")
187
+ async for event in stream:
188
+ self._consume(event)
189
+ if self._signal is not None and self._signal.aborted:
190
+ raise asyncio.CancelledError
191
+ if self.output["stopReason"] == "pending":
192
+ raise RuntimeError("Anthropic stream ended without a stop reason")
193
+ if self.output["stopReason"] == "error":
194
+ raise RuntimeError(cast(str, self.output.get("errorMessage") or "Provider returned an error"))
195
+ self.emit("done", reason=self.output["stopReason"], message=self.output)
196
+
197
+ # ── Event translation ────────────────────────────────────────────────
198
+
199
+ def _consume(self, event: Any) -> None:
200
+ kind = provider_field(event, "type")
201
+ if kind == "message_start":
202
+ message = provider_field(event, "message") or {}
203
+ self.output["responseId"] = provider_field(message, "id")
204
+ response_model = provider_field(message, "model")
205
+ if isinstance(response_model, str) and response_model and response_model != self._model.model_id:
206
+ self.output["responseModel"] = response_model
207
+ usage = provider_field(message, "usage")
208
+ if usage is not None:
209
+ self.output["usage"] = parse_usage(usage, self._model)
210
+ elif kind == "content_block_start":
211
+ self._start_block(provider_field(event, "index"), provider_field(event, "content_block"))
212
+ elif kind == "content_block_delta":
213
+ self._apply_delta(provider_field(event, "index"), provider_field(event, "delta"))
214
+ elif kind == "content_block_stop":
215
+ self._stop_block(provider_field(event, "index"))
216
+ elif kind == "message_delta":
217
+ delta = provider_field(event, "delta") or {}
218
+ stop_reason = provider_field(delta, "stop_reason")
219
+ if stop_reason is not None:
220
+ self.output["rawStopReason"] = stop_reason
221
+ mapped, error_message = map_stop_reason(
222
+ stop_reason, provider_field(delta, "stop_details")
223
+ )
224
+ self.output["stopReason"] = mapped
225
+ if error_message is not None:
226
+ self.output["errorMessage"] = error_message
227
+ usage = provider_field(event, "usage")
228
+ if usage is not None:
229
+ self._merge_usage(usage)
230
+ elif kind == "error":
231
+ error = provider_field(event, "error") or {}
232
+ raise RuntimeError(str(provider_field(error, "message") or "Anthropic stream error"))
233
+
234
+ def _start_block(self, index: int, content_block: Any) -> None:
235
+ kind = provider_field(content_block, "type")
236
+ block: dict[str, Any] | None = None
237
+ if kind == "text":
238
+ block = {"type": "text", "text": cast(str, provider_field(content_block, "text") or "")}
239
+ event_kind = "text_start"
240
+ elif kind == "thinking":
241
+ block = {
242
+ "type": "thinking",
243
+ "thinking": cast(str, provider_field(content_block, "thinking") or ""),
244
+ "thinkingSignature": cast(str, provider_field(content_block, "signature") or ""),
245
+ }
246
+ event_kind = "thinking_start"
247
+ elif kind == "redacted_thinking":
248
+ block = {
249
+ "type": "thinking",
250
+ "thinking": "[Reasoning redacted]",
251
+ "thinkingSignature": cast(str, provider_field(content_block, "data") or ""),
252
+ "redacted": True,
253
+ }
254
+ event_kind = "thinking_start"
255
+ elif kind == "tool_use":
256
+ block = {
257
+ "type": "toolCall",
258
+ "id": cast(str, provider_field(content_block, "id") or ""),
259
+ "name": cast(str, provider_field(content_block, "name") or ""),
260
+ "arguments": provider_mapping(provider_field(content_block, "input")),
261
+ }
262
+ self._tool_text[index] = ""
263
+ event_kind = "toolcall_start"
264
+ else:
265
+ return
266
+ self._blocks[index] = block
267
+ self.output["content"].append(block)
268
+ self.emit(event_kind, content_index=len(self.output["content"]) - 1)
269
+
270
+ def _apply_delta(self, index: int, delta: Any) -> None:
271
+ block = self._blocks.get(index)
272
+ if block is None:
273
+ return
274
+ kind = provider_field(delta, "type")
275
+ block_index = content_index(self.output["content"], block)
276
+ if kind == "text_delta" and block["type"] == "text":
277
+ text = cast(str, provider_field(delta, "text") or "")
278
+ block["text"] += text
279
+ self.emit("text_delta", content_index=block_index, delta=text)
280
+ elif kind == "thinking_delta" and block["type"] == "thinking":
281
+ thinking = cast(str, provider_field(delta, "thinking") or "")
282
+ block["thinking"] += thinking
283
+ self.emit("thinking_delta", content_index=block_index, delta=thinking)
284
+ elif kind == "signature_delta" and block["type"] == "thinking":
285
+ block["thinkingSignature"] += cast(str, provider_field(delta, "signature") or "")
286
+ elif kind == "input_json_delta" and block["type"] == "toolCall":
287
+ text = cast(str, provider_field(delta, "partial_json") or "")
288
+ self._tool_text[index] += text
289
+ block["arguments"] = parse_streaming_json(self._tool_text[index])
290
+ self.emit("toolcall_delta", content_index=block_index, delta=text)
291
+
292
+ def _stop_block(self, index: int) -> None:
293
+ block = self._blocks.pop(index, None)
294
+ if block is None:
295
+ return
296
+ block_index = content_index(self.output["content"], block)
297
+ kind = block["type"]
298
+ if kind == "text":
299
+ self.emit("text_end", content_index=block_index, content=block["text"])
300
+ elif kind == "thinking":
301
+ self.emit("thinking_end", content_index=block_index, content=block["thinking"])
302
+ elif kind == "toolCall":
303
+ text = self._tool_text.pop(index, "")
304
+ if text.strip():
305
+ arguments, error = parse_tool_call_arguments(text)
306
+ block["arguments"] = arguments
307
+ if error is not None:
308
+ block["argumentsError"] = error
309
+ self.emit("toolcall_end", content_index=block_index, toolCall=block)
310
+
311
+ def _merge_usage(self, raw: Any) -> None:
312
+ usage = parse_usage(raw, self._model, base=cast(dict[str, Any], self.output["usage"]))
313
+ self.output["usage"] = usage
314
+
315
+
316
+ def build_params(
317
+ model: Model,
318
+ context: Mapping[str, Any],
319
+ options: Mapping[str, Any],
320
+ *,
321
+ simple: bool,
322
+ ) -> dict[str, Any]:
323
+ """The Messages request body for one transcript."""
324
+
325
+ head, rest = collapse_system_messages(cast(Sequence[Mapping[str, Any]], context.get("messages") or ()))
326
+ transformed = transform_messages(
327
+ rest, model, api=ANTHROPIC_API, normalize_tool_call_id=normalize_tool_call_id
328
+ )
329
+ retention = str(options.get("cacheRetention") or "short")
330
+ cache_control = None if retention == "none" else _cache_control(retention)
331
+
332
+ messages = convert_messages(transformed)
333
+ params: dict[str, Any] = {
334
+ "model": model.model_id,
335
+ "messages": messages,
336
+ "stream": True,
337
+ }
338
+ text = system_message_text(head) if head is not None else ""
339
+ if text:
340
+ block: dict[str, Any] = {"type": "text", "text": text}
341
+ if cache_control is not None:
342
+ block["cache_control"] = cache_control
343
+ params["system"] = [block]
344
+
345
+ max_tokens = optional_int(options.get("maxTokens")) or model.max_tokens
346
+ if max_tokens <= 0:
347
+ max_tokens = DEFAULT_MAX_TOKENS
348
+ reasoning = options.get("reasoning")
349
+ if not simple:
350
+ reasoning = options.get("effort", reasoning)
351
+ if model.reasoning and isinstance(reasoning, str) and reasoning != "off":
352
+ budget = _THINKING_BUDGETS.get(reasoning)
353
+ if budget is not None:
354
+ max_tokens, budget = adjust_max_tokens_for_thinking(
355
+ optional_int(options.get("maxTokens")), model.max_tokens or max_tokens, budget
356
+ )
357
+ params["thinking"] = {"type": "enabled", "budget_tokens": budget}
358
+ elif "thinkingEnabled" in options and options["thinkingEnabled"] and model.reasoning:
359
+ budget = optional_int(options.get("thinkingBudgetTokens")) or 1_024
360
+ max_tokens, budget = adjust_max_tokens_for_thinking(
361
+ optional_int(options.get("maxTokens")), model.max_tokens or max_tokens, budget
362
+ )
363
+ params["thinking"] = {"type": "enabled", "budget_tokens": budget}
364
+ params["max_tokens"] = max_tokens
365
+
366
+ declared = (head.get("toolsAdded") or ()) if head is not None else ()
367
+ tools = convert_tools(cast(Sequence[Mapping[str, Any]], declared), cache_control)
368
+ if tools:
369
+ params["tools"] = tools
370
+
371
+ if "temperature" in options and options["temperature"] is not None and "thinking" not in params:
372
+ params["temperature"] = options["temperature"]
373
+ if "toolChoice" in options and options["toolChoice"] is not None:
374
+ params["tool_choice"] = options["toolChoice"]
375
+ metadata = options.get("metadata")
376
+ if isinstance(metadata, Mapping):
377
+ user_id = metadata.get("user_id")
378
+ if isinstance(user_id, str):
379
+ params["metadata"] = {"user_id": user_id}
380
+ return params
381
+
382
+
383
+ def convert_messages(messages: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
384
+ """Convert transformed pi-ai messages to Messages API messages."""
385
+
386
+ params: list[dict[str, Any]] = []
387
+ index = 0
388
+ while index < len(messages):
389
+ message = messages[index]
390
+ role = message.get("role")
391
+ if role == "user":
392
+ converted = _user_message(message)
393
+ if converted is not None:
394
+ params.append(converted)
395
+ elif role == "assistant":
396
+ converted = _assistant_message(message)
397
+ if converted is not None:
398
+ params.append(converted)
399
+ elif role == "toolResult":
400
+ blocks: list[dict[str, Any]] = []
401
+ while index < len(messages) and messages[index].get("role") == "toolResult":
402
+ blocks.append(_tool_result_block(messages[index]))
403
+ index += 1
404
+ params.append({"role": "user", "content": blocks})
405
+ continue
406
+ index += 1
407
+ return params
408
+
409
+
410
+ def _user_message(message: Mapping[str, Any]) -> dict[str, Any] | None:
411
+ content = message.get("content")
412
+ if isinstance(content, str):
413
+ return {"role": "user", "content": content} if content.strip() else None
414
+ blocks: list[dict[str, Any]] = []
415
+ for block in cast(Sequence[Mapping[str, Any]], content or ()):
416
+ if block.get("type") == "text":
417
+ text = cast(str, block.get("text") or "")
418
+ if text.strip():
419
+ blocks.append({"type": "text", "text": text})
420
+ elif block.get("type") == "image":
421
+ blocks.append(_image_block(block))
422
+ return {"role": "user", "content": blocks} if blocks else None
423
+
424
+
425
+ def _assistant_message(message: Mapping[str, Any]) -> dict[str, Any] | None:
426
+ blocks: list[dict[str, Any]] = []
427
+ for block in cast(Sequence[Mapping[str, Any]], message.get("content") or ()):
428
+ kind = block.get("type")
429
+ if kind == "text":
430
+ text = cast(str, block.get("text") or "")
431
+ if text.strip():
432
+ blocks.append({"type": "text", "text": text})
433
+ elif kind == "thinking":
434
+ if block.get("redacted"):
435
+ blocks.append(
436
+ {"type": "redacted_thinking", "data": block.get("thinkingSignature")}
437
+ )
438
+ continue
439
+ signature = block.get("thinkingSignature")
440
+ thinking = cast(str, block.get("thinking") or "")
441
+ if not thinking.strip():
442
+ continue
443
+ if not isinstance(signature, str) or not signature.strip():
444
+ # An unsigned block cannot be replayed as thinking; keep its text as visible context.
445
+ blocks.append({"type": "text", "text": thinking})
446
+ else:
447
+ blocks.append({"type": "thinking", "thinking": thinking, "signature": signature})
448
+ elif kind == "toolCall":
449
+ blocks.append(
450
+ {
451
+ "type": "tool_use",
452
+ "id": cast(str, block.get("id") or ""),
453
+ "name": cast(str, block.get("name") or ""),
454
+ "input": dict(cast(Mapping[str, Any], block.get("arguments") or {})),
455
+ }
456
+ )
457
+ return {"role": "assistant", "content": blocks} if blocks else None
458
+
459
+
460
+ def _tool_result_block(message: Mapping[str, Any]) -> dict[str, Any]:
461
+ raw = cast(Sequence[Mapping[str, Any]], message.get("content") or ())
462
+ blocks: list[dict[str, Any]] = []
463
+ for block in raw:
464
+ if block.get("type") == "text":
465
+ text = cast(str, block.get("text") or "")
466
+ if text:
467
+ blocks.append({"type": "text", "text": text})
468
+ elif block.get("type") == "image":
469
+ blocks.append(_image_block(block))
470
+ if not blocks:
471
+ blocks = [{"type": "text", "text": "(no tool output)"}]
472
+ return {
473
+ "type": "tool_result",
474
+ "tool_use_id": cast(str, message.get("toolCallId") or ""),
475
+ "content": blocks,
476
+ "is_error": bool(message.get("isError")),
477
+ }
478
+
479
+
480
+ def _image_block(block: Mapping[str, Any]) -> dict[str, Any]:
481
+ return {
482
+ "type": "image",
483
+ "source": {
484
+ "type": "base64",
485
+ "media_type": block.get("mimeType"),
486
+ "data": block.get("data"),
487
+ },
488
+ }
489
+
490
+
491
+ def convert_tools(
492
+ tools: Sequence[Mapping[str, Any]], cache_control: dict[str, Any] | None
493
+ ) -> list[dict[str, Any]]:
494
+ """Convert stored tool declarations to Messages tools, caching the last one."""
495
+
496
+ converted: list[dict[str, Any]] = []
497
+ for tool in tools:
498
+ definition: dict[str, Any] = {
499
+ "name": cast(str, tool.get("name") or ""),
500
+ "description": cast(str, tool.get("description") or ""),
501
+ "input_schema": tool.get("parameters") or {"type": "object", "properties": {}},
502
+ }
503
+ converted.append(definition)
504
+ if converted and cache_control is not None:
505
+ converted[-1]["cache_control"] = cache_control
506
+ return converted
507
+
508
+
509
+ def normalize_tool_call_id(value: str) -> str:
510
+ """Anthropic tool IDs use ``[a-zA-Z0-9_-]`` and at most 64 characters."""
511
+
512
+ return _TOOL_CALL_ID.sub("_", value)[:64]
513
+
514
+
515
+ def adjust_max_tokens_for_thinking(
516
+ base_max_tokens: int | None, model_max_tokens: int, thinking_budget: int
517
+ ) -> tuple[int, int]:
518
+ """Fit a thinking budget under the response ceiling, leaving room for the answer."""
519
+
520
+ max_tokens = (
521
+ model_max_tokens
522
+ if base_max_tokens is None
523
+ else min(base_max_tokens + thinking_budget, model_max_tokens)
524
+ )
525
+ if max_tokens <= thinking_budget:
526
+ thinking_budget = min(thinking_budget, max(0, max_tokens - _MIN_ANSWER_TOKENS))
527
+ return max_tokens, thinking_budget
528
+
529
+
530
+ def parse_usage(raw: Any, model: Model, *, base: Mapping[str, Any] | None = None) -> dict[str, Any]:
531
+ """Map an Anthropic usage object onto the pi-ai usage shape and price it."""
532
+
533
+ usage = zero_usage() if base is None else {**base, "cost": dict(base.get("cost") or {})}
534
+ input_tokens = optional_int(provider_field(raw, "input_tokens"))
535
+ output_tokens = optional_int(provider_field(raw, "output_tokens"))
536
+ cache_read = optional_int(provider_field(raw, "cache_read_input_tokens"))
537
+ cache_write = optional_int(provider_field(raw, "cache_creation_input_tokens"))
538
+ cache_creation = provider_field(raw, "cache_creation") or {}
539
+ cache_write_1h = optional_int(provider_field(cache_creation, "ephemeral_1h_input_tokens"))
540
+ details = provider_field(raw, "output_tokens_details") or {}
541
+ reasoning = optional_int(provider_field(details, "thinking_tokens"))
542
+ if input_tokens is not None:
543
+ usage["input"] = input_tokens
544
+ usage["output"] = output_tokens or 0
545
+ usage["cacheRead"] = cache_read or 0
546
+ usage["cacheWrite"] = cache_write or 0
547
+ if cache_write_1h is not None:
548
+ usage["cacheWrite1h"] = cache_write_1h
549
+ if reasoning is not None:
550
+ usage["reasoning"] = reasoning
551
+ usage["totalTokens"] = (
552
+ int(usage["input"])
553
+ + int(usage["output"])
554
+ + int(usage["cacheRead"])
555
+ + int(usage["cacheWrite"])
556
+ )
557
+ else:
558
+ if output_tokens is not None:
559
+ usage["output"] = output_tokens
560
+ if cache_read is not None:
561
+ usage["cacheRead"] = cache_read
562
+ if cache_write is not None:
563
+ usage["cacheWrite"] = cache_write
564
+ if cache_write_1h is not None:
565
+ usage["cacheWrite1h"] = cache_write_1h
566
+ if reasoning is not None:
567
+ usage["reasoning"] = reasoning
568
+ usage["totalTokens"] = (
569
+ int(usage.get("input") or 0)
570
+ + int(usage.get("output") or 0)
571
+ + int(usage.get("cacheRead") or 0)
572
+ + int(usage.get("cacheWrite") or 0)
573
+ )
574
+ calculate_cost(model, usage)
575
+ return usage
576
+
577
+
578
+ def map_stop_reason(reason: str, stop_details: Any = None) -> tuple[str, str | None]:
579
+ """Map an Anthropic ``stop_reason`` to a stop reason, with error text when it is a failure."""
580
+
581
+ if reason in _STOP_REASONS:
582
+ return _STOP_REASONS[reason], None
583
+ if reason == "refusal":
584
+ explanation = provider_field(stop_details, "explanation")
585
+ refused = (
586
+ explanation
587
+ if isinstance(explanation, str) and explanation
588
+ else "The model refused to complete the request"
589
+ )
590
+ return "error", refused
591
+ if reason == "sensitive":
592
+ return "error", "Provider stopped with: sensitive"
593
+ return "error", f"Unhandled stop reason: {reason}"
594
+
595
+
596
+ def _cache_control(retention: str) -> dict[str, Any]:
597
+ control: dict[str, Any] = {"type": "ephemeral"}
598
+ if retention == "long":
599
+ control["ttl"] = "1h"
600
+ return control
601
+
602
+
603
+ __all__ = ["ANTHROPIC_API", "AnthropicModels"]