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,362 @@
|
|
|
1
|
+
"""Provider-facing model protocol and the shared pi-ai helpers generation reads.
|
|
2
|
+
|
|
3
|
+
The port replaces pi-ai with a small ``Models`` protocol. The OpenAI and Anthropic adapters in the
|
|
4
|
+
optional extras implement it; the generation task only needs model lookup, a streaming request, a
|
|
5
|
+
non-streaming request, and the deferred-response calls. The classifiers mirror the pi-ai retry and
|
|
6
|
+
overflow utilities generation uses to decide an attempt's outcome.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import AsyncIterator, Awaitable, Mapping, Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Protocol, cast, runtime_checkable
|
|
17
|
+
|
|
18
|
+
DEFAULT_MAX_AGENT_RETRY_DELAY_MS = 60_000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Model:
|
|
23
|
+
"""One provider model's identity, limits, and input capabilities."""
|
|
24
|
+
|
|
25
|
+
provider: str
|
|
26
|
+
model_id: str
|
|
27
|
+
context_window: int = 0
|
|
28
|
+
max_tokens: int = 0
|
|
29
|
+
reasoning: bool = True
|
|
30
|
+
input: tuple[str, ...] = ("text", "image")
|
|
31
|
+
"""What the model takes inline; ``"image"`` enables the ``read`` image placeholder rule."""
|
|
32
|
+
|
|
33
|
+
input_limits: Mapping[str, Any] | None = None
|
|
34
|
+
"""Per-input limits, such as ``{"images": {"resize": {"max_width": 2000}}}``."""
|
|
35
|
+
|
|
36
|
+
cost: Mapping[str, float] | None = None
|
|
37
|
+
"""Flat USD per million tokens: ``input``, ``output``, ``cacheRead``, and ``cacheWrite``."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AssistantMessageStream(Protocol):
|
|
41
|
+
"""A pi-ai style stream: partial events while it runs, then the terminal message."""
|
|
42
|
+
|
|
43
|
+
def __aiter__(self) -> AsyncIterator[dict[str, Any]]: ...
|
|
44
|
+
|
|
45
|
+
def result(self) -> Awaitable[dict[str, Any]]: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@runtime_checkable
|
|
49
|
+
class Models(Protocol):
|
|
50
|
+
"""The model access generation and compaction use."""
|
|
51
|
+
|
|
52
|
+
def get_model(self, provider: str, model_id: str) -> Model | None: ...
|
|
53
|
+
|
|
54
|
+
def stream_simple(
|
|
55
|
+
self,
|
|
56
|
+
model: Model,
|
|
57
|
+
context: Mapping[str, Any],
|
|
58
|
+
options: Mapping[str, Any] | None = None,
|
|
59
|
+
) -> AssistantMessageStream: ...
|
|
60
|
+
|
|
61
|
+
def complete_simple(
|
|
62
|
+
self,
|
|
63
|
+
model: Model,
|
|
64
|
+
context: Mapping[str, Any],
|
|
65
|
+
options: Mapping[str, Any] | None = None,
|
|
66
|
+
) -> Awaitable[dict[str, Any]]: ...
|
|
67
|
+
|
|
68
|
+
def stream(
|
|
69
|
+
self,
|
|
70
|
+
model: Model,
|
|
71
|
+
context: Mapping[str, Any],
|
|
72
|
+
options: Mapping[str, Any] | None = None,
|
|
73
|
+
) -> AssistantMessageStream: ...
|
|
74
|
+
|
|
75
|
+
def complete(
|
|
76
|
+
self,
|
|
77
|
+
model: Model,
|
|
78
|
+
context: Mapping[str, Any],
|
|
79
|
+
options: Mapping[str, Any] | None = None,
|
|
80
|
+
) -> Awaitable[dict[str, Any]]: ...
|
|
81
|
+
|
|
82
|
+
def fetch_deferred(
|
|
83
|
+
self,
|
|
84
|
+
model: Model,
|
|
85
|
+
handle: Mapping[str, Any],
|
|
86
|
+
options: Mapping[str, Any] | None = None,
|
|
87
|
+
) -> Awaitable[dict[str, Any]]: ...
|
|
88
|
+
|
|
89
|
+
def cancel_deferred(
|
|
90
|
+
self,
|
|
91
|
+
model: Model,
|
|
92
|
+
handle: Mapping[str, Any],
|
|
93
|
+
options: Mapping[str, Any] | None = None,
|
|
94
|
+
) -> Awaitable[None]: ...
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Usage and token estimates ───────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
CHARS_PER_TOKEN = 3.5
|
|
100
|
+
ESTIMATED_IMAGE_CHARS = 4800
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def calculate_context_tokens(usage: Mapping[str, Any]) -> int:
|
|
104
|
+
"""The tokens a response's usage counts against its context window."""
|
|
105
|
+
|
|
106
|
+
total = usage.get("totalTokens")
|
|
107
|
+
if total:
|
|
108
|
+
return int(total)
|
|
109
|
+
return (
|
|
110
|
+
int(usage.get("input", 0))
|
|
111
|
+
+ int(usage.get("output", 0))
|
|
112
|
+
+ int(usage.get("cacheRead", 0))
|
|
113
|
+
+ int(usage.get("cacheWrite", 0))
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def estimate_text_tokens(text: str) -> int:
|
|
118
|
+
"""pi-ai's text estimate: one token per 3.5 characters, rounded up."""
|
|
119
|
+
|
|
120
|
+
return math.ceil(len(text) / CHARS_PER_TOKEN)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def estimate_text_and_image_content_tokens(content: Any) -> int:
|
|
124
|
+
"""Estimate a user or tool-result content: text characters plus a fixed cost per image."""
|
|
125
|
+
|
|
126
|
+
return math.ceil(_content_chars(content) / CHARS_PER_TOKEN)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def estimate_message_tokens(message: Mapping[str, Any]) -> int:
|
|
130
|
+
"""pi-ai's ``estimateMessageTokens()``, the estimate range selection and thresholds use."""
|
|
131
|
+
|
|
132
|
+
role = message.get("role")
|
|
133
|
+
if role == "system":
|
|
134
|
+
return (
|
|
135
|
+
estimate_text_tokens(_system_message_text(message))
|
|
136
|
+
+ _estimate_tools_tokens(message.get("toolsAdded"))
|
|
137
|
+
+ _estimate_tools_tokens(message.get("toolsRemoved"))
|
|
138
|
+
)
|
|
139
|
+
if role in ("user", "toolResult"):
|
|
140
|
+
return estimate_text_and_image_content_tokens(message.get("content"))
|
|
141
|
+
chars = 0
|
|
142
|
+
for block in cast(Sequence[Mapping[str, Any]], message.get("content") or ()):
|
|
143
|
+
kind = block.get("type")
|
|
144
|
+
if kind == "text":
|
|
145
|
+
chars += len(cast(str, block.get("text") or ""))
|
|
146
|
+
elif kind == "thinking":
|
|
147
|
+
chars += len(cast(str, block.get("thinking") or ""))
|
|
148
|
+
else:
|
|
149
|
+
chars += len(cast(str, block.get("name") or "")) + len(_safe_json(block.get("arguments")))
|
|
150
|
+
return math.ceil(chars / CHARS_PER_TOKEN)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _content_chars(content: Any) -> int:
|
|
154
|
+
if isinstance(content, str):
|
|
155
|
+
return len(content)
|
|
156
|
+
chars = 0
|
|
157
|
+
for block in cast(Sequence[Mapping[str, Any]], content or ()):
|
|
158
|
+
if block.get("type") == "text":
|
|
159
|
+
chars += len(cast(str, block.get("text") or ""))
|
|
160
|
+
else:
|
|
161
|
+
chars += ESTIMATED_IMAGE_CHARS
|
|
162
|
+
return chars
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _system_message_text(message: Mapping[str, Any]) -> str:
|
|
166
|
+
"""A system message as a complete prompt: its content followed by its sections."""
|
|
167
|
+
|
|
168
|
+
# Imported lazily because the transcript helpers live above ``Model`` in the package graph.
|
|
169
|
+
from .transcript import system_message_text
|
|
170
|
+
|
|
171
|
+
return system_message_text(message)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _estimate_tools_tokens(tools: Any) -> int:
|
|
175
|
+
if not tools:
|
|
176
|
+
return 0
|
|
177
|
+
return estimate_text_tokens(_safe_json(tools))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _safe_json(value: Any) -> str:
|
|
181
|
+
try:
|
|
182
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
183
|
+
except (TypeError, ValueError):
|
|
184
|
+
return "[unserializable]"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ── Retry classification ────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
_NON_RETRYABLE = re.compile(
|
|
190
|
+
"|".join(
|
|
191
|
+
[
|
|
192
|
+
"GoUsageLimitError",
|
|
193
|
+
"FreeUsageLimitError",
|
|
194
|
+
"Monthly usage limit reached",
|
|
195
|
+
"available balance",
|
|
196
|
+
"insufficient_quota",
|
|
197
|
+
"out of budget",
|
|
198
|
+
"quota exceeded",
|
|
199
|
+
"billing",
|
|
200
|
+
"subscription_sharing_usage_limit_exceeded",
|
|
201
|
+
]
|
|
202
|
+
),
|
|
203
|
+
re.IGNORECASE,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
_RETRYABLE = re.compile(
|
|
207
|
+
"|".join(
|
|
208
|
+
[
|
|
209
|
+
"overloaded",
|
|
210
|
+
"server_busy",
|
|
211
|
+
"servers are currently busy",
|
|
212
|
+
"currently experiencing high demand",
|
|
213
|
+
"model is at capacity",
|
|
214
|
+
"rate.?limit",
|
|
215
|
+
"too many requests",
|
|
216
|
+
"429",
|
|
217
|
+
"500",
|
|
218
|
+
"502",
|
|
219
|
+
"503",
|
|
220
|
+
"504",
|
|
221
|
+
"520",
|
|
222
|
+
"524",
|
|
223
|
+
"service.?(?:is.?)?(?:temporarily.?)?unavailable",
|
|
224
|
+
"server.?error",
|
|
225
|
+
"internal.?error",
|
|
226
|
+
"provider.?returned.?(?:an.?)?error",
|
|
227
|
+
"exceeded request buffer limit while retrying upstream",
|
|
228
|
+
"unable to forward upstream",
|
|
229
|
+
"network.?error",
|
|
230
|
+
"connection.?error",
|
|
231
|
+
"connection.?refused",
|
|
232
|
+
"connection.?lost",
|
|
233
|
+
"other side closed",
|
|
234
|
+
"fetch failed",
|
|
235
|
+
"getaddrinfo",
|
|
236
|
+
"ENOTFOUND",
|
|
237
|
+
"EAI_AGAIN",
|
|
238
|
+
"upstream.?connect",
|
|
239
|
+
"reset before headers",
|
|
240
|
+
"socket hang up",
|
|
241
|
+
"socket connection was closed",
|
|
242
|
+
"timed? out",
|
|
243
|
+
"timeout",
|
|
244
|
+
"terminated",
|
|
245
|
+
"websocket.?closed",
|
|
246
|
+
"websocket.?error",
|
|
247
|
+
"ended without",
|
|
248
|
+
"stream ended before message_stop",
|
|
249
|
+
"stream ended before a terminal response event",
|
|
250
|
+
"http2 request did not get a response",
|
|
251
|
+
"pending stream has been canceled",
|
|
252
|
+
"retry delay",
|
|
253
|
+
"you can retry your request",
|
|
254
|
+
"try your request again",
|
|
255
|
+
"please retry your request",
|
|
256
|
+
"ResourceExhausted",
|
|
257
|
+
"subscription_sharing_usage_unavailable",
|
|
258
|
+
"subscription_sharing_user_unavailable",
|
|
259
|
+
]
|
|
260
|
+
),
|
|
261
|
+
re.IGNORECASE,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def is_retryable_assistant_error(message: Mapping[str, Any]) -> bool:
|
|
266
|
+
"""Whether a failed assistant message looks like a transient provider or transport error."""
|
|
267
|
+
|
|
268
|
+
if message.get("stopReason") != "error":
|
|
269
|
+
return False
|
|
270
|
+
text = message.get("errorMessage")
|
|
271
|
+
if not isinstance(text, str) or not text:
|
|
272
|
+
return False
|
|
273
|
+
if _NON_RETRYABLE.search(text):
|
|
274
|
+
return False
|
|
275
|
+
return _RETRYABLE.search(text) is not None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def retry_delay_ms(policy: Mapping[str, Any], attempt: int) -> int:
|
|
279
|
+
"""Exponential backoff ``baseDelayMs * 2 ** (attempt - 1)``, capped at 60 seconds."""
|
|
280
|
+
|
|
281
|
+
base = int(policy.get("base_delay_ms", 2_000))
|
|
282
|
+
delay = base * (2 ** max(0, attempt - 1))
|
|
283
|
+
cap = int(policy.get("max_agent_delay_ms", DEFAULT_MAX_AGENT_RETRY_DELAY_MS))
|
|
284
|
+
return min(delay, cap)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# ── Overflow classification ─────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
_OVERFLOW = [
|
|
290
|
+
re.compile(pattern, re.IGNORECASE)
|
|
291
|
+
for pattern in (
|
|
292
|
+
r"prompt (?:is )?too long",
|
|
293
|
+
r"prompt exceeds max length",
|
|
294
|
+
r"request_too_large",
|
|
295
|
+
r"input is too long for requested model",
|
|
296
|
+
r"exceeds the context window",
|
|
297
|
+
r"exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))?",
|
|
298
|
+
r"input token count.*exceeds the maximum",
|
|
299
|
+
r"maximum prompt length is \d+",
|
|
300
|
+
r"reduce the length of the messages",
|
|
301
|
+
r"maximum context length is \d+ tokens",
|
|
302
|
+
r"exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?",
|
|
303
|
+
r"input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)",
|
|
304
|
+
r"exceeds the limit of \d+",
|
|
305
|
+
r"exceeds the available context size",
|
|
306
|
+
r"greater than the context length",
|
|
307
|
+
r"context window exceeds limit",
|
|
308
|
+
r"exceeded model token limit",
|
|
309
|
+
r"too large for model with \d+ maximum context length",
|
|
310
|
+
r"prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?",
|
|
311
|
+
r"model_context_window_exceeded",
|
|
312
|
+
r"prompt too long; exceeded (?:max )?context length",
|
|
313
|
+
r"range of input length should be",
|
|
314
|
+
r"context[_ ]length[_ ]exceeded",
|
|
315
|
+
r"too many tokens",
|
|
316
|
+
r"token limit exceeded",
|
|
317
|
+
)
|
|
318
|
+
]
|
|
319
|
+
_NON_OVERFLOW = [
|
|
320
|
+
re.compile(pattern, re.IGNORECASE)
|
|
321
|
+
for pattern in (
|
|
322
|
+
r"^(Throttling error|Service unavailable):",
|
|
323
|
+
r"rate limit",
|
|
324
|
+
r"too many requests",
|
|
325
|
+
)
|
|
326
|
+
]
|
|
327
|
+
_CEREBRAS_BODYLESS = re.compile(r"^4(?:00|13)\s*(?:status code)?\s*\(no body\)", re.IGNORECASE)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def is_context_overflow(message: Mapping[str, Any], context_window: int | None = None) -> bool:
|
|
331
|
+
"""Whether an assistant message reports or silently shows a context overflow."""
|
|
332
|
+
|
|
333
|
+
text = message.get("errorMessage")
|
|
334
|
+
if message.get("stopReason") == "error" and isinstance(text, str) and text:
|
|
335
|
+
if not any(pattern.search(text) for pattern in _NON_OVERFLOW):
|
|
336
|
+
if any(pattern.search(text) for pattern in _OVERFLOW):
|
|
337
|
+
return True
|
|
338
|
+
if message.get("provider") == "cerebras" and _CEREBRAS_BODYLESS.search(text):
|
|
339
|
+
return True
|
|
340
|
+
usage_value = message.get("usage") or {}
|
|
341
|
+
input_tokens = int(usage_value.get("input", 0)) + int(usage_value.get("cacheRead", 0))
|
|
342
|
+
if context_window and message.get("stopReason") == "stop" and input_tokens > context_window:
|
|
343
|
+
return True
|
|
344
|
+
if (
|
|
345
|
+
context_window
|
|
346
|
+
and message.get("stopReason") == "length"
|
|
347
|
+
and int(usage_value.get("output", 0)) == 0
|
|
348
|
+
and input_tokens >= context_window * 0.99
|
|
349
|
+
):
|
|
350
|
+
return True
|
|
351
|
+
return False
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
__all__ = [
|
|
355
|
+
"DEFAULT_MAX_AGENT_RETRY_DELAY_MS",
|
|
356
|
+
"AssistantMessageStream",
|
|
357
|
+
"Model",
|
|
358
|
+
"Models",
|
|
359
|
+
"is_context_overflow",
|
|
360
|
+
"is_retryable_assistant_error",
|
|
361
|
+
"retry_delay_ms",
|
|
362
|
+
]
|