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,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