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.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- 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"]
|