pi-python-core 0.8.1__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_python/__init__.py +160 -0
- pi_python/_version.py +1 -0
- pi_python/agent.py +396 -0
- pi_python/cancellation.py +24 -0
- pi_python/data/models.json +3315 -0
- pi_python/errors.py +49 -0
- pi_python/estimate.py +144 -0
- pi_python/events.py +138 -0
- pi_python/function_tools.py +438 -0
- pi_python/hooks.py +44 -0
- pi_python/limits.py +28 -0
- pi_python/loop.py +431 -0
- pi_python/lowlevel.py +179 -0
- pi_python/mcp.py +187 -0
- pi_python/messages.py +405 -0
- pi_python/models.py +155 -0
- pi_python/provider.py +123 -0
- pi_python/providers/__init__.py +21 -0
- pi_python/providers/anthropic.py +673 -0
- pi_python/providers/common.py +201 -0
- pi_python/providers/completions.py +1149 -0
- pi_python/providers/oauth.py +542 -0
- pi_python/providers/openai.py +681 -0
- pi_python/providers/transport.py +574 -0
- pi_python/proxy.py +304 -0
- pi_python/py.typed +0 -0
- pi_python/queues.py +76 -0
- pi_python/recovery.py +209 -0
- pi_python/run.py +419 -0
- pi_python/stream.py +251 -0
- pi_python/sync.py +78 -0
- pi_python/testing.py +25 -0
- pi_python/tools.py +546 -0
- pi_python/transcript.py +167 -0
- pi_python_core-0.8.1.dist-info/METADATA +119 -0
- pi_python_core-0.8.1.dist-info/RECORD +39 -0
- pi_python_core-0.8.1.dist-info/WHEEL +4 -0
- pi_python_core-0.8.1.dist-info/licenses/LICENSE +21 -0
- pi_python_core-0.8.1.dist-info/licenses/NOTICE +8 -0
pi_python/errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Stable exception classes; exceptions never imply that side effects were rolled back."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PiError(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(PiError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MessageValidationError(PiError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsupportedCapabilityError(MessageValidationError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProviderProtocolError(PiError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AgentBusyError(PiError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AgentClosedError(PiError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InvalidContinuationError(PiError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ToolOutcomeUnknownError(PiError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CleanupTimeoutError(PiError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SubscriptionError(PiError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CandidateValidationError(PiError):
|
|
49
|
+
pass
|
pi_python/estimate.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Context-size estimate ported from Pi a13d35a utils/estimate.ts (MIT); see NOTICE.
|
|
2
|
+
|
|
3
|
+
A character heuristic, not a tokenizer: four UTF-16 code units per token and a fixed
|
|
4
|
+
charge per image. It sizes output limits and compaction decisions; the service still
|
|
5
|
+
enforces the real context limit.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Any
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
from .messages import (
|
|
13
|
+
AssistantMessage,
|
|
14
|
+
ImageContent,
|
|
15
|
+
Message,
|
|
16
|
+
SystemMessage,
|
|
17
|
+
TextContent,
|
|
18
|
+
ThinkingContent,
|
|
19
|
+
ToolResultMessage,
|
|
20
|
+
UserMessage,
|
|
21
|
+
)
|
|
22
|
+
from .transcript import system_message_text
|
|
23
|
+
|
|
24
|
+
_CHARS_PER_TOKEN = 4
|
|
25
|
+
_IMAGE_CHARS = 4800
|
|
26
|
+
_SAFETY_TOKENS = 4096
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _length(text: str) -> int:
|
|
30
|
+
"""JavaScript string length: UTF-16 code units."""
|
|
31
|
+
return len(text.encode("utf-16-le", "surrogatepass")) // 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _json(value: Any) -> str:
|
|
35
|
+
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _tokens(chars: int) -> int:
|
|
39
|
+
return math.ceil(chars / _CHARS_PER_TOKEN)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _content_tokens(content: str | list[TextContent | ImageContent]) -> int:
|
|
43
|
+
if isinstance(content, str):
|
|
44
|
+
return _tokens(_length(content))
|
|
45
|
+
return _tokens(
|
|
46
|
+
sum(_length(b.text) if isinstance(b, TextContent) else _IMAGE_CHARS for b in content)
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def estimate_message_tokens(message: Message) -> int:
|
|
51
|
+
if isinstance(message, SystemMessage):
|
|
52
|
+
tokens = _tokens(_length(system_message_text(message)))
|
|
53
|
+
if message.tools_added:
|
|
54
|
+
tools = [
|
|
55
|
+
{"name": t.name, "description": t.description, "parameters": t.input_schema}
|
|
56
|
+
for t in message.tools_added
|
|
57
|
+
]
|
|
58
|
+
tokens += _tokens(_length(_json(tools)))
|
|
59
|
+
if message.tools_removed:
|
|
60
|
+
tokens += _tokens(_length(_json([{"name": n} for n in message.tools_removed])))
|
|
61
|
+
return tokens
|
|
62
|
+
if isinstance(message, (UserMessage, ToolResultMessage)):
|
|
63
|
+
return _content_tokens(message.content)
|
|
64
|
+
if isinstance(message, AssistantMessage):
|
|
65
|
+
chars = 0
|
|
66
|
+
for block in message.content:
|
|
67
|
+
if isinstance(block, TextContent):
|
|
68
|
+
chars += _length(block.text)
|
|
69
|
+
elif isinstance(block, ThinkingContent):
|
|
70
|
+
chars += _length(block.thinking)
|
|
71
|
+
elif not isinstance(block, ImageContent):
|
|
72
|
+
chars += _length(block.name) + _length(_json(block.arguments))
|
|
73
|
+
return _tokens(chars)
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _usage_tokens(usage: dict) -> int:
|
|
78
|
+
total = usage.get("total_tokens") or 0
|
|
79
|
+
return total or sum(
|
|
80
|
+
usage.get(k, 0) or 0 for k in ("input", "output", "cache_read", "cache_write")
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def estimate_context_tokens(messages: list[Message]) -> int:
|
|
85
|
+
"""Last valid reported usage plus an estimate of everything after it."""
|
|
86
|
+
latest = -math.inf
|
|
87
|
+
anchor = None
|
|
88
|
+
for index, message in enumerate(messages):
|
|
89
|
+
if isinstance(message, AssistantMessage):
|
|
90
|
+
# A newer prefix message inserted after this response invalidates its usage.
|
|
91
|
+
if (
|
|
92
|
+
message.timestamp >= latest
|
|
93
|
+
and message.stop_reason not in {"aborted", "error"}
|
|
94
|
+
and _usage_tokens(message.usage) > 0
|
|
95
|
+
):
|
|
96
|
+
anchor = index
|
|
97
|
+
latest = max(latest, message.timestamp)
|
|
98
|
+
if anchor is None:
|
|
99
|
+
return sum(estimate_message_tokens(m) for m in messages)
|
|
100
|
+
message = messages[anchor]
|
|
101
|
+
assert isinstance(message, AssistantMessage)
|
|
102
|
+
return _usage_tokens(message.usage) + sum(
|
|
103
|
+
estimate_message_tokens(m) for m in messages[anchor + 1 :]
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def clamp_max_tokens_to_context(
|
|
108
|
+
context_window: int, messages: list[Message], max_tokens: int
|
|
109
|
+
) -> int:
|
|
110
|
+
"""Pi clampMaxTokensToContext: leave room for the estimated prompt and a margin."""
|
|
111
|
+
if context_window <= 0:
|
|
112
|
+
return max(1, max_tokens)
|
|
113
|
+
available = context_window - estimate_context_tokens(messages) - _SAFETY_TOKENS
|
|
114
|
+
return min(max_tokens, max(1, available))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def short_hash(text: str) -> str:
|
|
118
|
+
"""Pi utils/hash.ts shortHash, bit for bit (32-bit Math.imul over UTF-16 units)."""
|
|
119
|
+
|
|
120
|
+
def imul(a: int, b: int) -> int:
|
|
121
|
+
result = (a * b) & 0xFFFFFFFF
|
|
122
|
+
return result - 0x100000000 if result & 0x80000000 else result
|
|
123
|
+
|
|
124
|
+
def u32(value: int) -> int:
|
|
125
|
+
return value & 0xFFFFFFFF
|
|
126
|
+
|
|
127
|
+
def base36(value: int) -> str:
|
|
128
|
+
digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
129
|
+
out = ""
|
|
130
|
+
while True:
|
|
131
|
+
value, rest = divmod(value, 36)
|
|
132
|
+
out = digits[rest] + out
|
|
133
|
+
if not value:
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
h1, h2 = 0xDEADBEEF, 0x41C6CE57
|
|
137
|
+
units = text.encode("utf-16-le", "surrogatepass")
|
|
138
|
+
for i in range(0, len(units), 2):
|
|
139
|
+
ch = units[i] | units[i + 1] << 8
|
|
140
|
+
h1 = imul(u32(h1) ^ ch, 2654435761)
|
|
141
|
+
h2 = imul(u32(h2) ^ ch, 1597334677)
|
|
142
|
+
h1 = imul(u32(h1) ^ (u32(h1) >> 16), 2246822507) ^ imul(u32(h2) ^ (u32(h2) >> 13), 3266489909)
|
|
143
|
+
h2 = imul(u32(h2) ^ (u32(h2) >> 16), 2246822507) ^ imul(u32(h1) ^ (u32(h1) >> 13), 3266489909)
|
|
144
|
+
return base36(u32(h2)) + base36(u32(h1))
|
pi_python/events.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Sequential critical delivery, detached payloads and independent failure diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import asyncio
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from dataclasses import asdict, dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
from .errors import MessageValidationError, SubscriptionError
|
|
12
|
+
from .messages import validate_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Event:
|
|
17
|
+
type: str
|
|
18
|
+
run_id: str
|
|
19
|
+
turn_id: int
|
|
20
|
+
sequence: int
|
|
21
|
+
call_id: str | None = None
|
|
22
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
schema_version: int = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
EventListener = Callable[[Event], Awaitable[None] | None]
|
|
27
|
+
Unsubscribe = Callable[[], None]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def encode_event(event: Event) -> str:
|
|
31
|
+
data = asdict(event)
|
|
32
|
+
validate_json(data)
|
|
33
|
+
decode_event(json.dumps(data))
|
|
34
|
+
return json.dumps(data, ensure_ascii=False, allow_nan=False)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def decode_event(value: str) -> Event:
|
|
38
|
+
try:
|
|
39
|
+
d = json.loads(value)
|
|
40
|
+
validate_json(d)
|
|
41
|
+
if type(d.get("schema_version")) is not int or d["schema_version"] != 1:
|
|
42
|
+
raise MessageValidationError("Unsupported event schema version")
|
|
43
|
+
e = Event(**d)
|
|
44
|
+
if (
|
|
45
|
+
not isinstance(e.type, str)
|
|
46
|
+
or not isinstance(e.run_id, str)
|
|
47
|
+
or not isinstance(e.data, dict)
|
|
48
|
+
):
|
|
49
|
+
raise MessageValidationError("Invalid event")
|
|
50
|
+
if (
|
|
51
|
+
type(e.sequence) is not int
|
|
52
|
+
or e.sequence < 1
|
|
53
|
+
or type(e.turn_id) is not int
|
|
54
|
+
or e.turn_id < 0
|
|
55
|
+
):
|
|
56
|
+
raise MessageValidationError("Invalid event sequence or turn")
|
|
57
|
+
if e.call_id is not None and not isinstance(e.call_id, str):
|
|
58
|
+
raise MessageValidationError("Invalid event call ID")
|
|
59
|
+
return e
|
|
60
|
+
except (ValueError, TypeError, AttributeError) as exc:
|
|
61
|
+
raise MessageValidationError(str(exc)) from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class EventDispatcher:
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self.listeners: list[EventListener] = []
|
|
67
|
+
self.diagnostics: list[dict[str, Any]] = []
|
|
68
|
+
self.failed = False
|
|
69
|
+
self.sequence = 0
|
|
70
|
+
self.lock = asyncio.Lock()
|
|
71
|
+
|
|
72
|
+
def subscribe(self, listener: EventListener) -> Unsubscribe:
|
|
73
|
+
self.listeners.append(listener)
|
|
74
|
+
active = True
|
|
75
|
+
|
|
76
|
+
def unsubscribe() -> None:
|
|
77
|
+
nonlocal active
|
|
78
|
+
if active:
|
|
79
|
+
active = False
|
|
80
|
+
self.listeners.remove(listener)
|
|
81
|
+
|
|
82
|
+
return unsubscribe
|
|
83
|
+
|
|
84
|
+
async def emit(
|
|
85
|
+
self,
|
|
86
|
+
kind: str,
|
|
87
|
+
run_id: str,
|
|
88
|
+
turn_id: int,
|
|
89
|
+
data: dict[str, Any],
|
|
90
|
+
call_id: str | None,
|
|
91
|
+
await_owned: Callable,
|
|
92
|
+
) -> None:
|
|
93
|
+
if self.failed:
|
|
94
|
+
return
|
|
95
|
+
async with self.lock:
|
|
96
|
+
if self.failed:
|
|
97
|
+
return
|
|
98
|
+
self.sequence += 1
|
|
99
|
+
event = Event(kind, run_id, turn_id, self.sequence, call_id, deepcopy(data))
|
|
100
|
+
encode_event(event)
|
|
101
|
+
listeners = list(self.listeners)
|
|
102
|
+
for index, listener in enumerate(listeners):
|
|
103
|
+
try:
|
|
104
|
+
result = listener(deepcopy(event))
|
|
105
|
+
if inspect.isawaitable(result):
|
|
106
|
+
await await_owned(result)
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
self.failed = True
|
|
109
|
+
self.diagnostics.append(
|
|
110
|
+
{
|
|
111
|
+
"event": kind,
|
|
112
|
+
"sequence": event.sequence,
|
|
113
|
+
"listener_index": index,
|
|
114
|
+
"unhandled_listener_indices": list(range(index + 1, len(listeners))),
|
|
115
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
raise SubscriptionError(f"{kind} subscriber {index} failed: {exc}") from exc
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class EventQueue:
|
|
122
|
+
"""A bounded display adapter. Only message deltas may be dropped, explicitly counted."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, maxsize: int = 128, *, drop_text_updates: bool = False):
|
|
125
|
+
if maxsize <= 0:
|
|
126
|
+
raise ValueError("maxsize must be positive")
|
|
127
|
+
self.queue: asyncio.Queue[Event] = asyncio.Queue(maxsize)
|
|
128
|
+
self.drop_text_updates = drop_text_updates
|
|
129
|
+
self.dropped_updates = 0
|
|
130
|
+
|
|
131
|
+
async def __call__(self, event: Event) -> None:
|
|
132
|
+
if self.drop_text_updates and event.type == "message_update" and self.queue.full():
|
|
133
|
+
self.dropped_updates += 1
|
|
134
|
+
return
|
|
135
|
+
await self.queue.put(deepcopy(event))
|
|
136
|
+
|
|
137
|
+
async def get(self) -> Event:
|
|
138
|
+
return await self.queue.get()
|