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,127 @@
|
|
|
1
|
+
"""Provider-level retries inside one request attempt, shared by the model adapters.
|
|
2
|
+
|
|
3
|
+
Port of pi-ai 1.3.0 ``utils/provider-retry.ts``. Both SDKs are called with their own retries
|
|
4
|
+
disabled (``max_retries=0``) and wrapped here instead, so a server-requested delay is interruptible
|
|
5
|
+
and a delay above ``maxRetryDelayMs`` fails at once.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from email.utils import parsedate_to_datetime
|
|
15
|
+
from typing import Any, TypeVar
|
|
16
|
+
|
|
17
|
+
from ..context import AbortSignal
|
|
18
|
+
from .stream import ABORT_MESSAGE, error_headers, error_status
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_RETRY_DELAY_MS = 60_000
|
|
23
|
+
RETRYABLE_STATUSES = frozenset({408, 409, 429})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_retryable_provider_error(error: BaseException) -> bool:
|
|
27
|
+
"""The SDK retry policy: an explicit header first, then status, then a transport failure."""
|
|
28
|
+
|
|
29
|
+
should_retry = _header(error_headers(error), "x-should-retry")
|
|
30
|
+
if should_retry == "true":
|
|
31
|
+
return True
|
|
32
|
+
if should_retry == "false":
|
|
33
|
+
return False
|
|
34
|
+
status = error_status(error)
|
|
35
|
+
if status is None:
|
|
36
|
+
return True
|
|
37
|
+
return status in RETRYABLE_STATUSES or status >= 500
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def provider_retry_delay_ms(
|
|
41
|
+
error: BaseException, retry_index: int, max_retry_delay_ms: int | None
|
|
42
|
+
) -> int:
|
|
43
|
+
"""How long to wait before the next provider attempt."""
|
|
44
|
+
|
|
45
|
+
headers = error_headers(error)
|
|
46
|
+
retry_after_ms = _header(headers, "retry-after-ms")
|
|
47
|
+
if retry_after_ms is not None:
|
|
48
|
+
delay = float(retry_after_ms)
|
|
49
|
+
_validate_delay(delay, max_retry_delay_ms, str(error))
|
|
50
|
+
return max(0, int(delay))
|
|
51
|
+
retry_after = _header(headers, "retry-after")
|
|
52
|
+
if retry_after is not None:
|
|
53
|
+
delay = _retry_after_ms(retry_after)
|
|
54
|
+
_validate_delay(delay, max_retry_delay_ms, str(error))
|
|
55
|
+
return max(0, int(delay))
|
|
56
|
+
exponential = min(0.5 * 2**retry_index, 8) * 1000
|
|
57
|
+
return int(exponential * (1 - random.random() * 0.25))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _retry_after_ms(value: str) -> float:
|
|
61
|
+
"""A ``Retry-After`` header, either delta-seconds or an HTTP date."""
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
return float(value) * 1000
|
|
65
|
+
except ValueError:
|
|
66
|
+
pass
|
|
67
|
+
try:
|
|
68
|
+
retry_at = parsedate_to_datetime(value)
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
return 0.0
|
|
71
|
+
if retry_at is None:
|
|
72
|
+
return 0.0
|
|
73
|
+
return (retry_at.timestamp() - time.time()) * 1000
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def retry_provider_request(
|
|
77
|
+
request: Callable[[], Awaitable[T]],
|
|
78
|
+
*,
|
|
79
|
+
max_retries: int = 0,
|
|
80
|
+
max_retry_delay_ms: int | None = None,
|
|
81
|
+
signal: AbortSignal | None = None,
|
|
82
|
+
) -> T:
|
|
83
|
+
"""Run ``request``, retrying retryable provider failures while attempts remain."""
|
|
84
|
+
|
|
85
|
+
remaining = max(0, max_retries)
|
|
86
|
+
for attempt in range(remaining + 1):
|
|
87
|
+
if signal is not None and signal.aborted:
|
|
88
|
+
raise asyncio.CancelledError(ABORT_MESSAGE)
|
|
89
|
+
try:
|
|
90
|
+
return await request()
|
|
91
|
+
except Exception as error:
|
|
92
|
+
if signal is not None and signal.aborted:
|
|
93
|
+
raise
|
|
94
|
+
if attempt >= remaining or not is_retryable_provider_error(error):
|
|
95
|
+
raise
|
|
96
|
+
delay = provider_retry_delay_ms(error, attempt, max_retry_delay_ms)
|
|
97
|
+
await asyncio.sleep(delay / 1000.0)
|
|
98
|
+
raise RuntimeError("unreachable") # pragma: no cover
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _validate_delay(delay_ms: float, max_retry_delay_ms: int | None, message: str) -> None:
|
|
102
|
+
limit = DEFAULT_MAX_RETRY_DELAY_MS if max_retry_delay_ms is None else max_retry_delay_ms
|
|
103
|
+
if limit > 0 and delay_ms > limit:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
f"Server requested {delay_ms / 1000:.0f}s retry delay (max: {limit / 1000:.0f}s). {message}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _header(headers: Any, name: str) -> str | None:
|
|
110
|
+
if headers is None:
|
|
111
|
+
return None
|
|
112
|
+
get = getattr(headers, "get", None)
|
|
113
|
+
if get is None:
|
|
114
|
+
return None
|
|
115
|
+
value: Any = get(name)
|
|
116
|
+
if value is None:
|
|
117
|
+
value = get(name.title())
|
|
118
|
+
return None if value is None else str(value)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = [
|
|
122
|
+
"DEFAULT_MAX_RETRY_DELAY_MS",
|
|
123
|
+
"RETRYABLE_STATUSES",
|
|
124
|
+
"is_retryable_provider_error",
|
|
125
|
+
"provider_retry_delay_ms",
|
|
126
|
+
"retry_provider_request",
|
|
127
|
+
]
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Async plumbing shared by the model adapters.
|
|
2
|
+
|
|
3
|
+
Each adapter turns one SDK request into a pi-ai style stream: partial events while the provider
|
|
4
|
+
responds, then a terminal assistant message. :class:`ProviderStream` owns the queue, the terminal
|
|
5
|
+
future, and the abort wiring so both adapters behave the same when a request fails or is cancelled.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import AsyncIterator, Awaitable, Mapping, Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..context import AbortSignal
|
|
16
|
+
|
|
17
|
+
ABORT_MESSAGE = "Request was aborted"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def provider_field(value: Any, key: str, default: Any = None) -> Any:
|
|
21
|
+
"""Read one field from an SDK object or a mapping."""
|
|
22
|
+
|
|
23
|
+
if isinstance(value, Mapping):
|
|
24
|
+
return value.get(key, default)
|
|
25
|
+
return getattr(value, key, default)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def provider_mapping(value: Any) -> dict[str, Any]:
|
|
29
|
+
"""Read a nested mapping from an SDK object, a mapping, or a value with ``model_dump()``."""
|
|
30
|
+
|
|
31
|
+
if isinstance(value, Mapping):
|
|
32
|
+
return dict(value)
|
|
33
|
+
dump = getattr(value, "model_dump", None)
|
|
34
|
+
if callable(dump):
|
|
35
|
+
dumped = dump()
|
|
36
|
+
return dict(dumped) if isinstance(dumped, Mapping) else {}
|
|
37
|
+
return dict(vars(value)) if hasattr(value, "__dict__") else {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def content_index(content: Sequence[Any], block: Any) -> int:
|
|
41
|
+
"""The index of ``block`` in ``content``, matched by identity rather than by value."""
|
|
42
|
+
|
|
43
|
+
for index, candidate in enumerate(content):
|
|
44
|
+
if candidate is block:
|
|
45
|
+
return index
|
|
46
|
+
raise ValueError("block is not part of the message content")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def error_status(error: BaseException) -> int | None:
|
|
50
|
+
"""The HTTP status an SDK error carries, if any."""
|
|
51
|
+
|
|
52
|
+
status = provider_field(error, "status_code")
|
|
53
|
+
if status is None:
|
|
54
|
+
status = provider_field(error, "status")
|
|
55
|
+
return status if isinstance(status, int) else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def error_headers(error: BaseException) -> Any:
|
|
59
|
+
"""The response headers an SDK error carries, if any."""
|
|
60
|
+
|
|
61
|
+
response = provider_field(error, "response")
|
|
62
|
+
headers = provider_field(response, "headers") if response is not None else None
|
|
63
|
+
return headers if headers is not None else provider_field(error, "headers")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def format_provider_error(error: BaseException) -> str:
|
|
67
|
+
"""The text a failed adapter attempt records, with the HTTP status when the SDK exposes it."""
|
|
68
|
+
|
|
69
|
+
message = str(error) or type(error).__name__
|
|
70
|
+
status = error_status(error)
|
|
71
|
+
if status is not None and str(status) not in message:
|
|
72
|
+
return f"{status}: {message}"
|
|
73
|
+
return message
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def now_ms() -> int:
|
|
77
|
+
"""A Unix timestamp in milliseconds, as pi-ai's ``Date.now()``."""
|
|
78
|
+
|
|
79
|
+
return int(time.time() * 1000)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def complete_stream(stream: Any) -> dict[str, Any]:
|
|
83
|
+
"""Consume a stream and return its terminal message."""
|
|
84
|
+
|
|
85
|
+
async for _event in stream:
|
|
86
|
+
pass
|
|
87
|
+
return await stream.result()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ProviderStream:
|
|
91
|
+
"""A pi-ai style stream over one asynchronous producer.
|
|
92
|
+
|
|
93
|
+
Subclasses implement :meth:`_produce`, pushing partial events with :meth:`push` and returning
|
|
94
|
+
once the terminal message is complete. Every ``BaseException`` becomes an ``error`` event and a
|
|
95
|
+
terminal message with ``stopReason`` ``error`` or, when the invocation was signalled, ``aborted``.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self, output: dict[str, Any], *, signal: AbortSignal | None = None) -> None:
|
|
99
|
+
self.output = output
|
|
100
|
+
self._signal = signal
|
|
101
|
+
self._queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
|
|
102
|
+
loop = asyncio.get_running_loop()
|
|
103
|
+
self._result: asyncio.Future[dict[str, Any]] = loop.create_future()
|
|
104
|
+
self._task: asyncio.Task[None] = asyncio.ensure_future(self._run())
|
|
105
|
+
|
|
106
|
+
# ── Stream surface ─────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
def push(self, event: dict[str, Any]) -> None:
|
|
109
|
+
self._queue.put_nowait(event)
|
|
110
|
+
|
|
111
|
+
def emit(self, kind: str, *, content_index: int | None = None, **fields: Any) -> None:
|
|
112
|
+
"""Push one assistant-message event carrying the live partial and any extra fields."""
|
|
113
|
+
|
|
114
|
+
event: dict[str, Any] = {"type": kind, "partial": self.output, **fields}
|
|
115
|
+
if content_index is not None:
|
|
116
|
+
event["contentIndex"] = content_index
|
|
117
|
+
self.push(event)
|
|
118
|
+
|
|
119
|
+
def result(self) -> Awaitable[dict[str, Any]]:
|
|
120
|
+
return self._result
|
|
121
|
+
|
|
122
|
+
def __aiter__(self) -> AsyncIterator[dict[str, Any]]:
|
|
123
|
+
return self._iterate()
|
|
124
|
+
|
|
125
|
+
async def _iterate(self) -> AsyncIterator[dict[str, Any]]:
|
|
126
|
+
try:
|
|
127
|
+
while True:
|
|
128
|
+
event = await self._queue.get()
|
|
129
|
+
if event is None:
|
|
130
|
+
return
|
|
131
|
+
yield event
|
|
132
|
+
finally:
|
|
133
|
+
# A consumer that stops early cannot leave the provider request running.
|
|
134
|
+
if not self._task.done():
|
|
135
|
+
self._task.cancel()
|
|
136
|
+
|
|
137
|
+
# ── Producer plumbing ─────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
async def _produce(self) -> None:
|
|
140
|
+
raise NotImplementedError
|
|
141
|
+
|
|
142
|
+
async def _run(self) -> None:
|
|
143
|
+
remove: Any = None
|
|
144
|
+
try:
|
|
145
|
+
if self._signal is not None:
|
|
146
|
+
if self._signal.aborted:
|
|
147
|
+
raise asyncio.CancelledError
|
|
148
|
+
remove = self._signal.add_listener(self._cancel)
|
|
149
|
+
await self._produce()
|
|
150
|
+
except asyncio.CancelledError:
|
|
151
|
+
self._fail("aborted", ABORT_MESSAGE)
|
|
152
|
+
except Exception as error:
|
|
153
|
+
aborted = self._signal is not None and self._signal.aborted
|
|
154
|
+
self._fail("aborted" if aborted else "error", format_provider_error(error))
|
|
155
|
+
finally:
|
|
156
|
+
if remove is not None:
|
|
157
|
+
remove()
|
|
158
|
+
if not self._result.done():
|
|
159
|
+
self._result.set_result(self.output)
|
|
160
|
+
self._queue.put_nowait(None)
|
|
161
|
+
|
|
162
|
+
def _cancel(self) -> None:
|
|
163
|
+
if not self._task.done():
|
|
164
|
+
self._task.cancel()
|
|
165
|
+
|
|
166
|
+
def _fail(self, stop_reason: str, message: str) -> None:
|
|
167
|
+
self.output["stopReason"] = stop_reason
|
|
168
|
+
self.output["errorMessage"] = message
|
|
169
|
+
self.emit("error", reason=stop_reason, error=self.output)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
__all__ = [
|
|
173
|
+
"ABORT_MESSAGE",
|
|
174
|
+
"ProviderStream",
|
|
175
|
+
"complete_stream",
|
|
176
|
+
"content_index",
|
|
177
|
+
"error_headers",
|
|
178
|
+
"error_status",
|
|
179
|
+
"format_provider_error",
|
|
180
|
+
"now_ms",
|
|
181
|
+
"provider_field",
|
|
182
|
+
"provider_mapping",
|
|
183
|
+
]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Provider-facing transcript replay shared by the model adapters.
|
|
2
|
+
|
|
3
|
+
Port of pi-ai 1.3.0 ``utils/transcript.ts`` and the cross-model parts of
|
|
4
|
+
``api/transform-messages.ts``. Durable hands every provider a pi-ai-shaped message list whose system
|
|
5
|
+
messages carry the prompt and tool changes. Adapters collapse those deltas into one leading system
|
|
6
|
+
message (pi-ai's default for providers without mid-conversation system support), replay tools, drop
|
|
7
|
+
thinking signed by another model, and replace images a model cannot take.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from . import Model
|
|
16
|
+
|
|
17
|
+
NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)"
|
|
18
|
+
NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def content_text(content: Any, separator: str = "\n") -> str:
|
|
22
|
+
"""Extract and join the text of a message content."""
|
|
23
|
+
|
|
24
|
+
if isinstance(content, str):
|
|
25
|
+
return content
|
|
26
|
+
return separator.join(
|
|
27
|
+
cast(str, block.get("text") or "")
|
|
28
|
+
for block in cast(Sequence[Mapping[str, Any]], content or ())
|
|
29
|
+
if block.get("type") == "text"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def system_message_text(message: Mapping[str, Any]) -> str:
|
|
34
|
+
"""Render a system message as a complete prompt: its content followed by its sections."""
|
|
35
|
+
|
|
36
|
+
parts = [content_text(message.get("content"))]
|
|
37
|
+
for value in cast(Mapping[str, str | None], message.get("sections") or {}).values():
|
|
38
|
+
if value is not None:
|
|
39
|
+
parts.append(value)
|
|
40
|
+
return "\n\n".join(part for part in parts if part)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def current_tools(messages: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
|
44
|
+
"""Resolve the tools available after applying every transcript delta in order."""
|
|
45
|
+
|
|
46
|
+
tools: dict[str, dict[str, Any]] = {}
|
|
47
|
+
for message in messages:
|
|
48
|
+
if message.get("role") != "system":
|
|
49
|
+
continue
|
|
50
|
+
for reference in cast(Sequence[Mapping[str, Any]], message.get("toolsRemoved") or ()):
|
|
51
|
+
tools.pop(cast(str, reference["name"]), None)
|
|
52
|
+
for tool in cast(Sequence[Mapping[str, Any]], message.get("toolsAdded") or ()):
|
|
53
|
+
tools[cast(str, tool["name"])] = dict(tool)
|
|
54
|
+
return list(tools.values())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def current_system_message(messages: Sequence[Mapping[str, Any]]) -> dict[str, Any] | None:
|
|
58
|
+
"""Replay every system message into one leading message holding the prompt and tools."""
|
|
59
|
+
|
|
60
|
+
contents: list[str] = []
|
|
61
|
+
sections: dict[str, str] = {}
|
|
62
|
+
timestamp: int | None = None
|
|
63
|
+
found = False
|
|
64
|
+
for message in messages:
|
|
65
|
+
if message.get("role") != "system":
|
|
66
|
+
continue
|
|
67
|
+
found = True
|
|
68
|
+
if timestamp is None:
|
|
69
|
+
timestamp = cast(int, message.get("timestamp") or 0)
|
|
70
|
+
text = content_text(message.get("content"))
|
|
71
|
+
if text:
|
|
72
|
+
contents.append(text)
|
|
73
|
+
for name, value in cast(Mapping[str, str | None], message.get("sections") or {}).items():
|
|
74
|
+
if value is None:
|
|
75
|
+
sections.pop(name, None)
|
|
76
|
+
else:
|
|
77
|
+
sections[name] = value
|
|
78
|
+
tools = current_tools(messages)
|
|
79
|
+
if not found:
|
|
80
|
+
return None
|
|
81
|
+
head: dict[str, Any] = {
|
|
82
|
+
"role": "system",
|
|
83
|
+
"content": "\n\n".join(contents),
|
|
84
|
+
"timestamp": 0 if timestamp is None else timestamp,
|
|
85
|
+
}
|
|
86
|
+
if sections:
|
|
87
|
+
head["sections"] = sections
|
|
88
|
+
if tools:
|
|
89
|
+
head["toolsAdded"] = tools
|
|
90
|
+
return head
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def collapse_system_messages(
|
|
94
|
+
messages: Sequence[Mapping[str, Any]],
|
|
95
|
+
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
|
96
|
+
"""Return the replayed leading system message and the conversation messages without system entries."""
|
|
97
|
+
|
|
98
|
+
head = current_system_message(messages)
|
|
99
|
+
rest = [dict(message) for message in messages if message.get("role") != "system"]
|
|
100
|
+
return head, rest
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def transform_messages(
|
|
104
|
+
messages: Sequence[Mapping[str, Any]],
|
|
105
|
+
model: Model,
|
|
106
|
+
*,
|
|
107
|
+
api: str,
|
|
108
|
+
normalize_tool_call_id: Callable[[str], str] | None = None,
|
|
109
|
+
) -> list[dict[str, Any]]:
|
|
110
|
+
"""Adapt a transcript to ``model``: image downgrade, cross-model thinking, and tool-call IDs.
|
|
111
|
+
|
|
112
|
+
Thinking signed by another model is dropped because signatures only replay for their producer.
|
|
113
|
+
A tool-call ID another provider minted is normalized when the adapter supplies a rule, and the
|
|
114
|
+
matching tool result is rewritten too. Failed and aborted assistant messages are skipped.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
tool_call_ids: dict[str, str] = {}
|
|
118
|
+
transformed: list[dict[str, Any]] = []
|
|
119
|
+
for message in messages:
|
|
120
|
+
role = message.get("role")
|
|
121
|
+
if role == "system":
|
|
122
|
+
transformed.append(dict(message))
|
|
123
|
+
continue
|
|
124
|
+
if role == "user":
|
|
125
|
+
transformed.append(_downgrade_images(dict(message), model, NON_VISION_USER_IMAGE_PLACEHOLDER))
|
|
126
|
+
continue
|
|
127
|
+
if role == "toolResult":
|
|
128
|
+
result = _downgrade_images(dict(message), model, NON_VISION_TOOL_IMAGE_PLACEHOLDER)
|
|
129
|
+
normalized = tool_call_ids.get(cast(str, message.get("toolCallId")))
|
|
130
|
+
if normalized is not None:
|
|
131
|
+
result["toolCallId"] = normalized
|
|
132
|
+
transformed.append(result)
|
|
133
|
+
continue
|
|
134
|
+
if role != "assistant":
|
|
135
|
+
transformed.append(dict(message))
|
|
136
|
+
continue
|
|
137
|
+
if message.get("stopReason") in ("error", "aborted"):
|
|
138
|
+
continue
|
|
139
|
+
same_model = (
|
|
140
|
+
message.get("provider") == model.provider
|
|
141
|
+
and message.get("api") == api
|
|
142
|
+
and message.get("model") == model.model_id
|
|
143
|
+
)
|
|
144
|
+
content: list[dict[str, Any]] = []
|
|
145
|
+
for block in cast(Sequence[Mapping[str, Any]], message.get("content") or ()):
|
|
146
|
+
kind = block.get("type")
|
|
147
|
+
if kind == "thinking":
|
|
148
|
+
if not same_model:
|
|
149
|
+
continue
|
|
150
|
+
if (
|
|
151
|
+
not block.get("redacted")
|
|
152
|
+
and not block.get("thinkingSignature")
|
|
153
|
+
and not block.get("thinking")
|
|
154
|
+
):
|
|
155
|
+
continue
|
|
156
|
+
content.append(dict(block))
|
|
157
|
+
elif kind == "text":
|
|
158
|
+
content.append(dict(block))
|
|
159
|
+
elif kind == "toolCall":
|
|
160
|
+
call = dict(block)
|
|
161
|
+
if not same_model:
|
|
162
|
+
call.pop("thoughtSignature", None)
|
|
163
|
+
if normalize_tool_call_id is not None:
|
|
164
|
+
original = cast(str, call.get("id") or "")
|
|
165
|
+
normalized = normalize_tool_call_id(original)
|
|
166
|
+
if normalized != original:
|
|
167
|
+
tool_call_ids[original] = normalized
|
|
168
|
+
call["id"] = normalized
|
|
169
|
+
content.append(call)
|
|
170
|
+
else:
|
|
171
|
+
content.append(dict(block))
|
|
172
|
+
assistant = dict(message)
|
|
173
|
+
assistant["content"] = content
|
|
174
|
+
transformed.append(assistant)
|
|
175
|
+
return transformed
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _downgrade_images(
|
|
179
|
+
message: dict[str, Any], model: Model, placeholder: str
|
|
180
|
+
) -> dict[str, Any]:
|
|
181
|
+
if "image" in model.input:
|
|
182
|
+
return message
|
|
183
|
+
content = message.get("content")
|
|
184
|
+
if not isinstance(content, (list, tuple)):
|
|
185
|
+
return message
|
|
186
|
+
replaced: list[dict[str, Any]] = []
|
|
187
|
+
previous_was_placeholder = False
|
|
188
|
+
for block in cast(Sequence[Mapping[str, Any]], content):
|
|
189
|
+
if block.get("type") != "image":
|
|
190
|
+
replaced.append(dict(block))
|
|
191
|
+
previous_was_placeholder = block.get("type") == "text" and block.get("text") == placeholder
|
|
192
|
+
continue
|
|
193
|
+
if not previous_was_placeholder:
|
|
194
|
+
replaced.append({"type": "text", "text": placeholder})
|
|
195
|
+
previous_was_placeholder = True
|
|
196
|
+
message["content"] = replaced
|
|
197
|
+
return message
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def has_tool_history(messages: Sequence[Mapping[str, Any]]) -> bool:
|
|
201
|
+
"""Whether the transcript carries any tool call or tool result."""
|
|
202
|
+
|
|
203
|
+
for message in messages:
|
|
204
|
+
if message.get("role") == "toolResult":
|
|
205
|
+
return True
|
|
206
|
+
if message.get("role") == "assistant" and any(
|
|
207
|
+
block.get("type") == "toolCall"
|
|
208
|
+
for block in cast(Sequence[Mapping[str, Any]], message.get("content") or ())
|
|
209
|
+
):
|
|
210
|
+
return True
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = [
|
|
215
|
+
"NON_VISION_TOOL_IMAGE_PLACEHOLDER",
|
|
216
|
+
"NON_VISION_USER_IMAGE_PLACEHOLDER",
|
|
217
|
+
"collapse_system_messages",
|
|
218
|
+
"content_text",
|
|
219
|
+
"current_system_message",
|
|
220
|
+
"current_tools",
|
|
221
|
+
"has_tool_history",
|
|
222
|
+
"system_message_text",
|
|
223
|
+
"transform_messages",
|
|
224
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Provider usage accounting shared by the model adapters.
|
|
2
|
+
|
|
3
|
+
Port of the pi-ai 1.3.0 usage shape and ``calculateCost()``. Every adapter reports tokens in the
|
|
4
|
+
same four buckets and prices them at the model's catalog rates so the durable usage ledger sums
|
|
5
|
+
providers without knowing which one produced a response.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from . import Model
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def zero_cost() -> dict[str, float]:
|
|
16
|
+
"""A complete pi-ai ``Usage.cost`` value with every rate at zero."""
|
|
17
|
+
|
|
18
|
+
return {"input": 0.0, "output": 0.0, "cacheRead": 0.0, "cacheWrite": 0.0, "total": 0.0}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def zero_usage() -> dict[str, Any]:
|
|
22
|
+
"""A complete pi-ai ``Usage`` value with every counter at zero."""
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
"input": 0,
|
|
26
|
+
"output": 0,
|
|
27
|
+
"cacheRead": 0,
|
|
28
|
+
"cacheWrite": 0,
|
|
29
|
+
"totalTokens": 0,
|
|
30
|
+
"cost": zero_cost(),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def calculate_cost(model: Model, usage: dict[str, Any]) -> dict[str, float]:
|
|
35
|
+
"""Fill ``usage["cost"]`` from the model's USD-per-million-token rates."""
|
|
36
|
+
|
|
37
|
+
rates = model.cost or {}
|
|
38
|
+
input_rate = float(rates.get("input", 0.0))
|
|
39
|
+
output_rate = float(rates.get("output", 0.0))
|
|
40
|
+
cache_read_rate = float(rates.get("cacheRead", 0.0))
|
|
41
|
+
cache_write_rate = float(rates.get("cacheWrite", 0.0))
|
|
42
|
+
long_write = int(usage.get("cacheWrite1h") or 0)
|
|
43
|
+
short_write = max(0, int(usage.get("cacheWrite") or 0) - long_write)
|
|
44
|
+
cost = {
|
|
45
|
+
"input": input_rate / 1_000_000 * int(usage.get("input") or 0),
|
|
46
|
+
"output": output_rate / 1_000_000 * int(usage.get("output") or 0),
|
|
47
|
+
"cacheRead": cache_read_rate / 1_000_000 * int(usage.get("cacheRead") or 0),
|
|
48
|
+
"cacheWrite": (
|
|
49
|
+
cache_write_rate * short_write + input_rate * 2 * long_write
|
|
50
|
+
)
|
|
51
|
+
/ 1_000_000,
|
|
52
|
+
}
|
|
53
|
+
cost["total"] = cost["input"] + cost["output"] + cost["cacheRead"] + cost["cacheWrite"]
|
|
54
|
+
usage["cost"] = cost
|
|
55
|
+
return cost
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = ["calculate_cost", "zero_cost", "zero_usage"]
|
pi_durable/py.typed
ADDED
|
File without changes
|