pi-agent-python-sdk 0.1.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_agent/__init__.py +47 -0
- pi_agent/_events.py +108 -0
- pi_agent/_launch.py +203 -0
- pi_agent/_runs.py +317 -0
- pi_agent/_transport.py +360 -0
- pi_agent/_usage.py +88 -0
- pi_agent/client.py +796 -0
- pi_agent/errors.py +87 -0
- pi_agent/py.typed +0 -0
- pi_agent/sync.py +684 -0
- pi_agent/types.py +960 -0
- pi_agent_python_sdk-0.1.0.dist-info/METADATA +231 -0
- pi_agent_python_sdk-0.1.0.dist-info/RECORD +15 -0
- pi_agent_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- pi_agent_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
pi_agent/_transport.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Owned subprocess I/O. Only this module reads Pi's stdout or writes stdin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import PiCommandError, PiProcessError, PiProtocolError, PiTimeoutError
|
|
12
|
+
from .types import Limits
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _wait_for_exit(process: asyncio.subprocess.Process) -> None:
|
|
16
|
+
"""Observe child exit without waiting for inherited pipes to reach EOF."""
|
|
17
|
+
while process.returncode is None: # noqa: ASYNC110 - asyncio exposes no pipe-independent exit event
|
|
18
|
+
await asyncio.sleep(0.01)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _close_process_pipes(process: asyncio.subprocess.Process) -> None:
|
|
22
|
+
"""Release this process's pipe handles after the owned child has exited."""
|
|
23
|
+
assert process.returncode is not None
|
|
24
|
+
# StreamReader has no public close(). Isolate the asyncio implementation
|
|
25
|
+
# seam needed when a descendant retains its own copies of the pipes. The
|
|
26
|
+
# owned child has exited, so close() cannot signal it or any descendants.
|
|
27
|
+
process._transport.close() # type: ignore[attr-defined]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class _Pending:
|
|
32
|
+
command: str
|
|
33
|
+
future: asyncio.Future[dict[str, Any]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Transport:
|
|
37
|
+
"""Route checked responses and unsolicited events from one owned child."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
limits: Limits,
|
|
43
|
+
on_event: Callable[[dict[str, Any]], None],
|
|
44
|
+
on_failure: Callable[[Exception], None],
|
|
45
|
+
) -> None:
|
|
46
|
+
self._limits = limits
|
|
47
|
+
self._on_event = on_event
|
|
48
|
+
self._on_failure = on_failure
|
|
49
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
50
|
+
self._spawning: asyncio.Task[asyncio.subprocess.Process] | None = None
|
|
51
|
+
self._pending: dict[str, _Pending] = {}
|
|
52
|
+
self._next_id = 0
|
|
53
|
+
self._write_lock = asyncio.Lock()
|
|
54
|
+
self._reader: asyncio.Task[None] | None = None
|
|
55
|
+
self._stderr_reader: asyncio.Task[None] | None = None
|
|
56
|
+
self._exit_monitor: asyncio.Task[None] | None = None
|
|
57
|
+
self._closing: asyncio.Task[None] | None = None
|
|
58
|
+
self._failure: Exception | None = None
|
|
59
|
+
self._stderr = bytearray()
|
|
60
|
+
self._stdout_buffer = bytearray()
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def running(self) -> bool:
|
|
64
|
+
return (
|
|
65
|
+
self._process is not None and self._process.returncode is None and self._failure is None
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def stderr_tail(self) -> str:
|
|
70
|
+
"""Opt-in bounded diagnostic text; never included in exceptions."""
|
|
71
|
+
return self._stderr.decode("utf-8", errors="replace")
|
|
72
|
+
|
|
73
|
+
async def start(
|
|
74
|
+
self,
|
|
75
|
+
argv: Sequence[str],
|
|
76
|
+
cwd: str | None = None,
|
|
77
|
+
env: dict[str, str] | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
if self._spawning is not None or self._failure is not None:
|
|
80
|
+
raise PiProcessError("Transport cannot be started twice")
|
|
81
|
+
if not argv:
|
|
82
|
+
raise ValueError("argv cannot be empty")
|
|
83
|
+
try:
|
|
84
|
+
self._spawning = asyncio.create_task(
|
|
85
|
+
asyncio.create_subprocess_exec(
|
|
86
|
+
*argv,
|
|
87
|
+
stdin=asyncio.subprocess.PIPE,
|
|
88
|
+
stdout=asyncio.subprocess.PIPE,
|
|
89
|
+
stderr=asyncio.subprocess.PIPE,
|
|
90
|
+
cwd=cwd,
|
|
91
|
+
env=env,
|
|
92
|
+
),
|
|
93
|
+
name="pi-spawn",
|
|
94
|
+
)
|
|
95
|
+
# Keep ownership of a child created just as the caller is cancelled.
|
|
96
|
+
self._process = await asyncio.shield(self._spawning)
|
|
97
|
+
except asyncio.CancelledError:
|
|
98
|
+
await self.aclose()
|
|
99
|
+
raise
|
|
100
|
+
except (OSError, ValueError) as exc:
|
|
101
|
+
error = PiProcessError("Could not start Pi subprocess")
|
|
102
|
+
self._fail(error)
|
|
103
|
+
raise error from exc
|
|
104
|
+
if self._failure is not None:
|
|
105
|
+
await self.aclose()
|
|
106
|
+
raise self._failure
|
|
107
|
+
self._reader = asyncio.create_task(self._read_stdout(), name="pi-stdout")
|
|
108
|
+
self._stderr_reader = asyncio.create_task(self._read_stderr(), name="pi-stderr")
|
|
109
|
+
self._exit_monitor = asyncio.create_task(self._monitor_exit(), name="pi-exit")
|
|
110
|
+
|
|
111
|
+
async def request(
|
|
112
|
+
self,
|
|
113
|
+
command: str,
|
|
114
|
+
fields: dict[str, Any] | None = None,
|
|
115
|
+
*,
|
|
116
|
+
timeout: float | None = 30, # noqa: ASYNC109 - per-response API, distinct from write bound
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
if not isinstance(command, str) or not command:
|
|
119
|
+
raise ValueError("command must be a nonempty string")
|
|
120
|
+
if fields is not None and {"id", "type"}.intersection(fields):
|
|
121
|
+
raise ValueError("Request fields cannot override id or type")
|
|
122
|
+
self._check_running()
|
|
123
|
+
self._next_id += 1
|
|
124
|
+
request_id = str(self._next_id)
|
|
125
|
+
future: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
|
|
126
|
+
# A fast child can respond while drain() is still suspended.
|
|
127
|
+
self._pending[request_id] = _Pending(command, future)
|
|
128
|
+
try:
|
|
129
|
+
await self._write({"type": command, "id": request_id, **(fields or {})})
|
|
130
|
+
try:
|
|
131
|
+
# Keep cancellation on this task: wait_for can lose cancellation
|
|
132
|
+
# when its separate waiter completes at the same time on Python 3.11.
|
|
133
|
+
async with asyncio.timeout(timeout):
|
|
134
|
+
return await asyncio.shield(future)
|
|
135
|
+
except TimeoutError as exc:
|
|
136
|
+
raise PiTimeoutError(
|
|
137
|
+
"Pi response timed out; the operation may still be running",
|
|
138
|
+
command=command,
|
|
139
|
+
request_id=request_id,
|
|
140
|
+
uncertain=True,
|
|
141
|
+
) from exc
|
|
142
|
+
finally:
|
|
143
|
+
self._pending.pop(request_id, None)
|
|
144
|
+
if not future.done():
|
|
145
|
+
future.cancel()
|
|
146
|
+
elif not future.cancelled():
|
|
147
|
+
# A failed write can race a reader failure that rejected the future.
|
|
148
|
+
future.exception()
|
|
149
|
+
|
|
150
|
+
async def send_ui(self, record: dict[str, Any]) -> None:
|
|
151
|
+
"""Send a dialog reply with its original ID, without awaiting an ack."""
|
|
152
|
+
if record.get("type") != "extension_ui_response" or not isinstance(record.get("id"), str):
|
|
153
|
+
raise ValueError("UI replies require type extension_ui_response and a string id")
|
|
154
|
+
await self._write(record)
|
|
155
|
+
|
|
156
|
+
def _check_running(self) -> None:
|
|
157
|
+
if self._failure is not None:
|
|
158
|
+
raise self._failure
|
|
159
|
+
if not self.running:
|
|
160
|
+
raise PiProcessError("Pi subprocess is not running")
|
|
161
|
+
|
|
162
|
+
async def _write(self, record: dict[str, Any]) -> None:
|
|
163
|
+
data = json.dumps(
|
|
164
|
+
record, ensure_ascii=False, allow_nan=False, separators=(",", ":")
|
|
165
|
+
).encode("utf-8")
|
|
166
|
+
if len(data) > self._limits.max_record_bytes:
|
|
167
|
+
raise ValueError("Outbound JSON record exceeds max_record_bytes")
|
|
168
|
+
wrote = False
|
|
169
|
+
try:
|
|
170
|
+
# Bound lock acquisition too: a wedged pipe must not queue writers forever.
|
|
171
|
+
async with asyncio.timeout(self._limits.command_timeout):
|
|
172
|
+
async with self._write_lock:
|
|
173
|
+
self._check_running()
|
|
174
|
+
assert self._process is not None and self._process.stdin is not None
|
|
175
|
+
wrote = True
|
|
176
|
+
self._process.stdin.write(data + b"\n")
|
|
177
|
+
await self._process.stdin.drain()
|
|
178
|
+
except TimeoutError as exc:
|
|
179
|
+
error = PiTimeoutError("Pi stdin write timed out", uncertain=wrote)
|
|
180
|
+
if wrote:
|
|
181
|
+
self._fail(error)
|
|
182
|
+
raise error from exc
|
|
183
|
+
except asyncio.CancelledError:
|
|
184
|
+
if wrote:
|
|
185
|
+
self._fail(PiProcessError("Pi stdin write was interrupted"))
|
|
186
|
+
raise
|
|
187
|
+
except (BrokenPipeError, ConnectionError, OSError) as exc:
|
|
188
|
+
process_error = PiProcessError("Pi stdin closed while writing")
|
|
189
|
+
self._fail(process_error)
|
|
190
|
+
raise process_error from exc
|
|
191
|
+
|
|
192
|
+
async def _read_stdout(self) -> None:
|
|
193
|
+
assert self._process is not None and self._process.stdout is not None
|
|
194
|
+
buffer = self._stdout_buffer
|
|
195
|
+
try:
|
|
196
|
+
while chunk := await self._process.stdout.read(65536):
|
|
197
|
+
buffer.extend(chunk)
|
|
198
|
+
offset = 0
|
|
199
|
+
while (end := buffer.find(b"\n", offset)) >= 0:
|
|
200
|
+
self._record(bytes(buffer[offset:end]))
|
|
201
|
+
offset = end + 1
|
|
202
|
+
if offset:
|
|
203
|
+
del buffer[:offset]
|
|
204
|
+
if len(buffer) > self._limits.max_record_bytes:
|
|
205
|
+
raise PiProtocolError("Pi JSON record exceeds max_record_bytes")
|
|
206
|
+
if buffer:
|
|
207
|
+
self._record(bytes(buffer))
|
|
208
|
+
buffer.clear()
|
|
209
|
+
# EOF itself is terminal even if an extension left the process alive.
|
|
210
|
+
self._fail(PiProcessError("Pi stdout closed", returncode=self._process.returncode))
|
|
211
|
+
except asyncio.CancelledError:
|
|
212
|
+
raise
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
self._fail(exc)
|
|
215
|
+
|
|
216
|
+
async def _monitor_exit(self) -> None:
|
|
217
|
+
assert self._process is not None
|
|
218
|
+
await _wait_for_exit(self._process)
|
|
219
|
+
# Let already-ready stdout callbacks run before declaring requests lost.
|
|
220
|
+
# A descendant may retain the pipe, so EOF cannot be our exit signal.
|
|
221
|
+
await asyncio.sleep(0)
|
|
222
|
+
if self._failure is None:
|
|
223
|
+
try:
|
|
224
|
+
if self._stdout_buffer:
|
|
225
|
+
self._record(bytes(self._stdout_buffer))
|
|
226
|
+
self._stdout_buffer.clear()
|
|
227
|
+
except Exception as exc:
|
|
228
|
+
self._fail(exc)
|
|
229
|
+
return
|
|
230
|
+
self._fail(PiProcessError("Pi subprocess exited", returncode=self._process.returncode))
|
|
231
|
+
|
|
232
|
+
def _record(self, data: bytes) -> None:
|
|
233
|
+
if data.endswith(b"\r"):
|
|
234
|
+
data = data[:-1]
|
|
235
|
+
if len(data) > self._limits.max_record_bytes:
|
|
236
|
+
raise PiProtocolError("Pi JSON record exceeds max_record_bytes")
|
|
237
|
+
if not data:
|
|
238
|
+
return
|
|
239
|
+
try:
|
|
240
|
+
record = json.loads(data.decode("utf-8"), parse_constant=self._invalid_constant)
|
|
241
|
+
except (ValueError, UnicodeError, RecursionError) as exc:
|
|
242
|
+
raise PiProtocolError("Pi emitted invalid UTF-8 JSON") from exc
|
|
243
|
+
if not isinstance(record, dict) or not isinstance(record.get("type"), str):
|
|
244
|
+
raise PiProtocolError("Pi record requires an object with a string type")
|
|
245
|
+
if record["type"] != "response":
|
|
246
|
+
self._on_event(record)
|
|
247
|
+
return
|
|
248
|
+
request_id = record.get("id")
|
|
249
|
+
if (
|
|
250
|
+
not isinstance(record.get("command"), str)
|
|
251
|
+
or not isinstance(record.get("success"), bool)
|
|
252
|
+
or (request_id is not None and not isinstance(request_id, str))
|
|
253
|
+
or (record.get("success") is False and not isinstance(record.get("error"), str))
|
|
254
|
+
):
|
|
255
|
+
raise PiProtocolError("Malformed Pi response envelope")
|
|
256
|
+
if request_id is None:
|
|
257
|
+
raise PiProtocolError("Pi emitted an uncorrelated response")
|
|
258
|
+
pending = self._pending.get(request_id)
|
|
259
|
+
if pending is None or pending.future.done():
|
|
260
|
+
# Late and duplicate replies are not events and cannot match future IDs.
|
|
261
|
+
return
|
|
262
|
+
if pending.command != record["command"]:
|
|
263
|
+
raise PiProtocolError("Pi response command does not match its request")
|
|
264
|
+
if record["success"]:
|
|
265
|
+
pending.future.set_result(record)
|
|
266
|
+
else:
|
|
267
|
+
pending.future.set_exception(
|
|
268
|
+
PiCommandError(pending.command, record["error"], request_id=request_id)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _invalid_constant(value: str) -> None:
|
|
273
|
+
raise ValueError(f"Non-JSON numeric constant: {value}")
|
|
274
|
+
|
|
275
|
+
async def _read_stderr(self) -> None:
|
|
276
|
+
assert self._process is not None and self._process.stderr is not None
|
|
277
|
+
try:
|
|
278
|
+
while chunk := await self._process.stderr.read(65536):
|
|
279
|
+
if self._limits.stderr_tail_bytes:
|
|
280
|
+
self._stderr.extend(chunk)
|
|
281
|
+
del self._stderr[: -self._limits.stderr_tail_bytes]
|
|
282
|
+
except asyncio.CancelledError:
|
|
283
|
+
raise
|
|
284
|
+
except Exception:
|
|
285
|
+
self._fail(PiProcessError("Could not drain Pi stderr"))
|
|
286
|
+
|
|
287
|
+
def _fail(self, error: Exception) -> None:
|
|
288
|
+
if self._failure is not None:
|
|
289
|
+
return
|
|
290
|
+
self._failure = error
|
|
291
|
+
for pending in self._pending.values():
|
|
292
|
+
if not pending.future.done():
|
|
293
|
+
pending.future.set_exception(error)
|
|
294
|
+
# Start teardown before callbacks so their failure cannot orphan the child.
|
|
295
|
+
self._closing = asyncio.create_task(self._finish_close(), name="pi-close")
|
|
296
|
+
self._on_failure(error)
|
|
297
|
+
|
|
298
|
+
async def aclose(self) -> None:
|
|
299
|
+
"""Close stdin, then terminate/kill as necessary and reap the child."""
|
|
300
|
+
self._fail(PiProcessError("Pi client closed"))
|
|
301
|
+
assert self._closing is not None
|
|
302
|
+
await asyncio.shield(self._closing)
|
|
303
|
+
|
|
304
|
+
async def _finish_close(self) -> None:
|
|
305
|
+
discard: asyncio.Task[None] | None = None
|
|
306
|
+
try:
|
|
307
|
+
if self._spawning is not None:
|
|
308
|
+
try:
|
|
309
|
+
self._process = await self._spawning
|
|
310
|
+
except (OSError, ValueError):
|
|
311
|
+
return
|
|
312
|
+
process = self._process
|
|
313
|
+
if process is not None:
|
|
314
|
+
# Once terminal, discard stdout but keep draining it. A full pipe can
|
|
315
|
+
# prevent asyncio's process.wait() finishing even after the child dies.
|
|
316
|
+
if self._reader is not None:
|
|
317
|
+
self._reader.cancel()
|
|
318
|
+
await asyncio.gather(self._reader, return_exceptions=True)
|
|
319
|
+
if process.stdout is not None:
|
|
320
|
+
discard = asyncio.create_task(self._discard(process.stdout))
|
|
321
|
+
if self._stderr_reader is None:
|
|
322
|
+
self._stderr_reader = asyncio.create_task(self._read_stderr())
|
|
323
|
+
if process.stdin is not None:
|
|
324
|
+
process.stdin.close()
|
|
325
|
+
try:
|
|
326
|
+
await asyncio.wait_for(_wait_for_exit(process), self._limits.cleanup_timeout)
|
|
327
|
+
except TimeoutError:
|
|
328
|
+
if process.returncode is None:
|
|
329
|
+
try:
|
|
330
|
+
process.terminate()
|
|
331
|
+
except ProcessLookupError:
|
|
332
|
+
pass
|
|
333
|
+
try:
|
|
334
|
+
await asyncio.wait_for(
|
|
335
|
+
_wait_for_exit(process), self._limits.cleanup_timeout
|
|
336
|
+
)
|
|
337
|
+
except TimeoutError:
|
|
338
|
+
if process.returncode is None:
|
|
339
|
+
try:
|
|
340
|
+
process.kill()
|
|
341
|
+
except ProcessLookupError:
|
|
342
|
+
pass
|
|
343
|
+
await _wait_for_exit(process)
|
|
344
|
+
_close_process_pipes(process)
|
|
345
|
+
await process.wait()
|
|
346
|
+
finally:
|
|
347
|
+
tasks = [
|
|
348
|
+
task
|
|
349
|
+
for task in (self._reader, self._stderr_reader, self._exit_monitor, discard)
|
|
350
|
+
if task is not None
|
|
351
|
+
]
|
|
352
|
+
for task in tasks:
|
|
353
|
+
if not task.done():
|
|
354
|
+
task.cancel()
|
|
355
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
async def _discard(stream: asyncio.StreamReader) -> None:
|
|
359
|
+
while await stream.read(65536):
|
|
360
|
+
pass
|
pi_agent/_usage.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Incremental assistant accounting for core run results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .types import UsageSummary
|
|
9
|
+
|
|
10
|
+
_TOKENS = {
|
|
11
|
+
"input_tokens": "input",
|
|
12
|
+
"output_tokens": "output",
|
|
13
|
+
"cache_read_tokens": "cacheRead",
|
|
14
|
+
"cache_write_tokens": "cacheWrite",
|
|
15
|
+
"cache_write_1h_tokens": "cacheWrite1h",
|
|
16
|
+
"reasoning_tokens": "reasoning",
|
|
17
|
+
"total_tokens": "totalTokens",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UsageAccumulator:
|
|
22
|
+
"""Keep only totals; a missing or invalid measurement stays unknown."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._totals: dict[str, int | None] = dict.fromkeys(_TOKENS, 0)
|
|
26
|
+
self._cost: float | None = 0.0
|
|
27
|
+
self._count = 0
|
|
28
|
+
self._observed = False
|
|
29
|
+
|
|
30
|
+
def add(self, message: dict[str, Any]) -> None:
|
|
31
|
+
if message.get("role") != "assistant":
|
|
32
|
+
return
|
|
33
|
+
self._count += 1
|
|
34
|
+
raw = message.get("usage")
|
|
35
|
+
self._observed |= isinstance(raw, dict)
|
|
36
|
+
usage = raw if isinstance(raw, dict) else {}
|
|
37
|
+
for field, wire in _TOKENS.items():
|
|
38
|
+
value = usage.get(wire)
|
|
39
|
+
count = (
|
|
40
|
+
value
|
|
41
|
+
if isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
42
|
+
else None
|
|
43
|
+
)
|
|
44
|
+
if field == "reasoning_tokens" and count is not None:
|
|
45
|
+
output = usage.get("output")
|
|
46
|
+
content = message.get("content")
|
|
47
|
+
thinking = isinstance(content, list) and any(
|
|
48
|
+
isinstance(block, dict)
|
|
49
|
+
and block.get("type") == "thinking"
|
|
50
|
+
and block.get("thinking")
|
|
51
|
+
for block in content
|
|
52
|
+
)
|
|
53
|
+
valid = (
|
|
54
|
+
isinstance(output, int)
|
|
55
|
+
and not isinstance(output, bool)
|
|
56
|
+
and count <= output
|
|
57
|
+
and not (count == 0 and thinking)
|
|
58
|
+
)
|
|
59
|
+
if not valid:
|
|
60
|
+
count = None
|
|
61
|
+
previous = self._totals[field]
|
|
62
|
+
self._totals[field] = (
|
|
63
|
+
previous + count if previous is not None and count is not None else None
|
|
64
|
+
)
|
|
65
|
+
cost = usage.get("cost")
|
|
66
|
+
amount = cost.get("total") if isinstance(cost, dict) else None
|
|
67
|
+
if isinstance(amount, (int, float)) and not isinstance(amount, bool):
|
|
68
|
+
try:
|
|
69
|
+
amount = float(amount)
|
|
70
|
+
except OverflowError:
|
|
71
|
+
amount = None
|
|
72
|
+
if (
|
|
73
|
+
self._cost is not None
|
|
74
|
+
and isinstance(amount, (int, float))
|
|
75
|
+
and not isinstance(amount, bool)
|
|
76
|
+
and math.isfinite(amount)
|
|
77
|
+
and amount >= 0
|
|
78
|
+
):
|
|
79
|
+
self._cost += amount
|
|
80
|
+
if not math.isfinite(self._cost):
|
|
81
|
+
self._cost = None
|
|
82
|
+
else:
|
|
83
|
+
self._cost = None
|
|
84
|
+
|
|
85
|
+
def snapshot(self) -> UsageSummary | None:
|
|
86
|
+
if not self._observed:
|
|
87
|
+
return None
|
|
88
|
+
return UsageSummary(**self._totals, cost=self._cost, assistant_messages=self._count)
|