pi-ai-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_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
pi_agent/proxy.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Proxy stream function for apps that route LLM calls through a server.
|
|
2
|
+
|
|
3
|
+
The server manages auth and proxies requests to providers. It strips the
|
|
4
|
+
``partial`` field from delta events to reduce bandwidth; the partial message is
|
|
5
|
+
reconstructed client-side.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import Any, TypedDict
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from pi_ai import (
|
|
17
|
+
AbortSignal,
|
|
18
|
+
AssistantMessage,
|
|
19
|
+
AssistantMessageEventStream,
|
|
20
|
+
DoneEvent,
|
|
21
|
+
ErrorEvent,
|
|
22
|
+
Model,
|
|
23
|
+
StartEvent,
|
|
24
|
+
StreamingJsonParser,
|
|
25
|
+
TextContent,
|
|
26
|
+
TextDeltaEvent,
|
|
27
|
+
TextEndEvent,
|
|
28
|
+
TextStartEvent,
|
|
29
|
+
ThinkingContent,
|
|
30
|
+
ThinkingDeltaEvent,
|
|
31
|
+
ThinkingEndEvent,
|
|
32
|
+
ThinkingStartEvent,
|
|
33
|
+
ToolCall,
|
|
34
|
+
ToolCallDeltaEvent,
|
|
35
|
+
ToolCallEndEvent,
|
|
36
|
+
ToolCallStartEvent,
|
|
37
|
+
TranscriptContext,
|
|
38
|
+
Usage,
|
|
39
|
+
abort_scope,
|
|
40
|
+
)
|
|
41
|
+
from pi_ai.serialization import content_from_dict, from_dict, to_json_value
|
|
42
|
+
from pi_ai.utils.http import get_http_client
|
|
43
|
+
from pi_ai.utils.text import append_text, append_thinking
|
|
44
|
+
|
|
45
|
+
__all__ = ["ProxyStreamOptions", "stream_proxy", "process_proxy_event"]
|
|
46
|
+
|
|
47
|
+
#: Simple stream options forwarded to the proxy server (snake_case keys are sent as pi's camelCase).
|
|
48
|
+
_SERIALIZABLE_OPTIONS = (
|
|
49
|
+
"temperature",
|
|
50
|
+
"sampling_params",
|
|
51
|
+
"max_tokens",
|
|
52
|
+
"reasoning",
|
|
53
|
+
"cache_retention",
|
|
54
|
+
"session_id",
|
|
55
|
+
"headers",
|
|
56
|
+
"metadata",
|
|
57
|
+
"transport",
|
|
58
|
+
"thinking_budgets",
|
|
59
|
+
"max_retry_delay_ms",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ProxyStreamOptions(TypedDict, total=False):
|
|
64
|
+
#: Local abort signal for the proxy request.
|
|
65
|
+
signal: AbortSignal
|
|
66
|
+
#: Auth token for the proxy server.
|
|
67
|
+
auth_token: str
|
|
68
|
+
#: Proxy server URL, e.g. ``"https://genai.example.com"``.
|
|
69
|
+
proxy_url: str
|
|
70
|
+
http_client: httpx.AsyncClient
|
|
71
|
+
temperature: float
|
|
72
|
+
sampling_params: dict[str, Any]
|
|
73
|
+
max_tokens: int
|
|
74
|
+
reasoning: str
|
|
75
|
+
cache_retention: str
|
|
76
|
+
session_id: str
|
|
77
|
+
headers: dict[str, str | None]
|
|
78
|
+
metadata: dict[str, Any]
|
|
79
|
+
transport: str
|
|
80
|
+
thinking_budgets: dict[str, int]
|
|
81
|
+
max_retry_delay_ms: float
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _request_options(options: Mapping[str, Any]) -> dict[str, Any]:
|
|
85
|
+
result: dict[str, Any] = {}
|
|
86
|
+
for key in _SERIALIZABLE_OPTIONS:
|
|
87
|
+
value = options.get(key)
|
|
88
|
+
if value is None:
|
|
89
|
+
continue
|
|
90
|
+
camel = "".join(part if index == 0 else part.capitalize() for index, part in enumerate(key.split("_")))
|
|
91
|
+
result[camel] = value
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def stream_proxy(model: Model, context: TranscriptContext, options: Mapping[str, Any]) -> AssistantMessageEventStream:
|
|
96
|
+
"""Stream through a pi proxy server (``POST {proxy_url}/api/stream``) instead of a provider.
|
|
97
|
+
|
|
98
|
+
Use it as the agent's ``stream_fn``::
|
|
99
|
+
|
|
100
|
+
Agent(stream_fn=lambda model, context, options: stream_proxy(
|
|
101
|
+
model, context, {**(options or {}), "auth_token": token, "proxy_url": "https://genai.example.com"}))
|
|
102
|
+
"""
|
|
103
|
+
options = dict(options)
|
|
104
|
+
stream = AssistantMessageEventStream()
|
|
105
|
+
|
|
106
|
+
async def run() -> None:
|
|
107
|
+
partial = AssistantMessage(
|
|
108
|
+
[], api=model.api, provider=model.provider, model=model.id, usage=Usage(), stop_reason="pending"
|
|
109
|
+
)
|
|
110
|
+
parsers: dict[int, StreamingJsonParser] = {}
|
|
111
|
+
signal: AbortSignal | None = options.get("signal")
|
|
112
|
+
try:
|
|
113
|
+
async with abort_scope(signal):
|
|
114
|
+
proxy_url = str(options.get("proxy_url", "")).rstrip("/")
|
|
115
|
+
client = get_http_client(proxy_url, options)
|
|
116
|
+
body = json.dumps(
|
|
117
|
+
{
|
|
118
|
+
"model": to_json_value(model),
|
|
119
|
+
"context": to_json_value(context),
|
|
120
|
+
"options": _request_options(options),
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
request = client.build_request(
|
|
124
|
+
"POST",
|
|
125
|
+
f"{proxy_url}/api/stream",
|
|
126
|
+
headers={
|
|
127
|
+
"Authorization": f"Bearer {options.get('auth_token', '')}",
|
|
128
|
+
"Content-Type": "application/json",
|
|
129
|
+
},
|
|
130
|
+
content=body,
|
|
131
|
+
)
|
|
132
|
+
response = await client.send(request, stream=True)
|
|
133
|
+
try:
|
|
134
|
+
if response.status_code >= 400:
|
|
135
|
+
text = (await response.aread()).decode("utf-8", "replace")
|
|
136
|
+
message = f"Proxy error: {response.status_code} {response.reason_phrase}"
|
|
137
|
+
try:
|
|
138
|
+
data = json.loads(text)
|
|
139
|
+
if isinstance(data, dict) and data.get("error"):
|
|
140
|
+
message = f"Proxy error: {data['error']}"
|
|
141
|
+
except ValueError:
|
|
142
|
+
pass
|
|
143
|
+
raise RuntimeError(message)
|
|
144
|
+
saw_terminal = False
|
|
145
|
+
async for line in response.aiter_lines():
|
|
146
|
+
if not line.startswith("data: "):
|
|
147
|
+
continue
|
|
148
|
+
data = line[6:].strip()
|
|
149
|
+
if not data:
|
|
150
|
+
continue
|
|
151
|
+
event = process_proxy_event(json.loads(data), partial, parsers)
|
|
152
|
+
if event is not None:
|
|
153
|
+
if event.type in ("done", "error"):
|
|
154
|
+
saw_terminal = True
|
|
155
|
+
stream.push(event)
|
|
156
|
+
finally:
|
|
157
|
+
await response.aclose()
|
|
158
|
+
if signal is not None and signal.aborted:
|
|
159
|
+
raise RuntimeError("Request aborted by user")
|
|
160
|
+
if not saw_terminal:
|
|
161
|
+
# A clean EOF without done/error means the server dropped the response mid-stream.
|
|
162
|
+
partial.stop_reason = "error"
|
|
163
|
+
partial.error_message = "Connection closed by proxy server before the response completed"
|
|
164
|
+
stream.push(ErrorEvent("error", partial))
|
|
165
|
+
stream.end()
|
|
166
|
+
except Exception as error:
|
|
167
|
+
reason = "aborted" if signal is not None and signal.aborted else "error"
|
|
168
|
+
partial.stop_reason = reason
|
|
169
|
+
partial.error_message = str(error) or type(error).__name__
|
|
170
|
+
stream.push(ErrorEvent(reason, partial))
|
|
171
|
+
stream.end()
|
|
172
|
+
|
|
173
|
+
stream.start(run())
|
|
174
|
+
return stream
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _usage(data: Any) -> Usage:
|
|
178
|
+
return from_dict(Usage, data) if isinstance(data, Mapping) else Usage()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def process_proxy_event(
|
|
182
|
+
event: Mapping[str, Any], partial: AssistantMessage, parsers: dict[int, StreamingJsonParser] | None = None
|
|
183
|
+
) -> Any:
|
|
184
|
+
"""Apply one proxy event to ``partial`` and return the matching assistant message event."""
|
|
185
|
+
parsers = parsers if parsers is not None else {}
|
|
186
|
+
kind = event.get("type")
|
|
187
|
+
index = event.get("contentIndex", 0)
|
|
188
|
+
content = partial.content
|
|
189
|
+
|
|
190
|
+
def block() -> Any:
|
|
191
|
+
return content[index] if 0 <= index < len(content) else None
|
|
192
|
+
|
|
193
|
+
def place(value: Any) -> None:
|
|
194
|
+
while len(content) <= index:
|
|
195
|
+
content.append(TextContent(""))
|
|
196
|
+
content[index] = value
|
|
197
|
+
|
|
198
|
+
if kind == "start":
|
|
199
|
+
return StartEvent(partial)
|
|
200
|
+
if kind == "text_start":
|
|
201
|
+
place(TextContent(""))
|
|
202
|
+
return TextStartEvent(index, partial)
|
|
203
|
+
if kind == "text_delta":
|
|
204
|
+
current = block()
|
|
205
|
+
if isinstance(current, TextContent):
|
|
206
|
+
append_text(current, event["delta"])
|
|
207
|
+
return TextDeltaEvent(index, event["delta"], partial)
|
|
208
|
+
raise ValueError("Received text_delta for non-text content")
|
|
209
|
+
if kind == "text_end":
|
|
210
|
+
current = block()
|
|
211
|
+
if isinstance(current, TextContent):
|
|
212
|
+
current.text_signature = event.get("contentSignature")
|
|
213
|
+
return TextEndEvent(index, current.text, partial)
|
|
214
|
+
raise ValueError("Received text_end for non-text content")
|
|
215
|
+
if kind == "thinking_start":
|
|
216
|
+
place(ThinkingContent(""))
|
|
217
|
+
return ThinkingStartEvent(index, partial)
|
|
218
|
+
if kind == "thinking_delta":
|
|
219
|
+
current = block()
|
|
220
|
+
if isinstance(current, ThinkingContent):
|
|
221
|
+
append_thinking(current, event["delta"])
|
|
222
|
+
return ThinkingDeltaEvent(index, event["delta"], partial)
|
|
223
|
+
raise ValueError("Received thinking_delta for non-thinking content")
|
|
224
|
+
if kind == "thinking_end":
|
|
225
|
+
current = block()
|
|
226
|
+
if isinstance(current, ThinkingContent):
|
|
227
|
+
current.thinking_signature = event.get("contentSignature")
|
|
228
|
+
return ThinkingEndEvent(index, current.thinking, partial)
|
|
229
|
+
raise ValueError("Received thinking_end for non-thinking content")
|
|
230
|
+
if kind == "toolcall_start":
|
|
231
|
+
place(ToolCall(event.get("id", ""), event.get("toolName", ""), {}))
|
|
232
|
+
parsers[index] = StreamingJsonParser()
|
|
233
|
+
return ToolCallStartEvent(index, partial)
|
|
234
|
+
if kind == "toolcall_delta":
|
|
235
|
+
current = block()
|
|
236
|
+
if isinstance(current, ToolCall):
|
|
237
|
+
parser = parsers.setdefault(index, StreamingJsonParser())
|
|
238
|
+
parser.feed(event["delta"])
|
|
239
|
+
current.arguments = parser.value()
|
|
240
|
+
return ToolCallDeltaEvent(index, event["delta"], partial)
|
|
241
|
+
raise ValueError("Received toolcall_delta for non-toolCall content")
|
|
242
|
+
if kind == "toolcall_end":
|
|
243
|
+
current = block()
|
|
244
|
+
if isinstance(current, ToolCall):
|
|
245
|
+
final = content_from_dict(event.get("toolCall") or {})
|
|
246
|
+
if isinstance(final, ToolCall):
|
|
247
|
+
current.id = final.id or current.id
|
|
248
|
+
current.name = final.name or current.name
|
|
249
|
+
current.arguments = final.arguments
|
|
250
|
+
current.thought_signature = final.thought_signature
|
|
251
|
+
current.namespace = final.namespace
|
|
252
|
+
current.arguments_error = final.arguments_error
|
|
253
|
+
parsers.pop(index, None)
|
|
254
|
+
return ToolCallEndEvent(index, current, partial)
|
|
255
|
+
return None
|
|
256
|
+
if kind == "done":
|
|
257
|
+
partial.stop_reason = event["reason"]
|
|
258
|
+
partial.usage = _usage(event.get("usage"))
|
|
259
|
+
if event.get("providerThinkingLevel") is not None:
|
|
260
|
+
partial.provider_thinking_level = event["providerThinkingLevel"]
|
|
261
|
+
return DoneEvent(event["reason"], partial)
|
|
262
|
+
if kind == "error":
|
|
263
|
+
partial.stop_reason = event["reason"]
|
|
264
|
+
partial.error_message = event.get("errorMessage")
|
|
265
|
+
partial.usage = _usage(event.get("usage"))
|
|
266
|
+
if event.get("providerThinkingLevel") is not None:
|
|
267
|
+
partial.provider_thinking_level = event["providerThinkingLevel"]
|
|
268
|
+
return ErrorEvent(event["reason"], partial)
|
|
269
|
+
return None
|
pi_agent/py.typed
ADDED
|
File without changes
|
pi_agent/stream_fn.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Process-wide fallback stream function."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .types import StreamFn
|
|
6
|
+
|
|
7
|
+
__all__ = ["set_default_stream_fn", "get_default_stream_fn"]
|
|
8
|
+
|
|
9
|
+
_default_stream_fn: StreamFn | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def set_default_stream_fn(stream_fn: StreamFn | None) -> None:
|
|
13
|
+
"""Configure the fallback used by ``Agent`` and the low-level loops when ``stream_fn`` is omitted.
|
|
14
|
+
|
|
15
|
+
Hosts that provide a default model runtime install its stream function here, e.g.
|
|
16
|
+
``set_default_stream_fn(models.stream_simple)``.
|
|
17
|
+
"""
|
|
18
|
+
global _default_stream_fn
|
|
19
|
+
_default_stream_fn = stream_fn
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_default_stream_fn() -> StreamFn:
|
|
23
|
+
if _default_stream_fn is None:
|
|
24
|
+
raise RuntimeError(
|
|
25
|
+
"No default stream function configured. Pass stream_fn explicitly or call set_default_stream_fn()."
|
|
26
|
+
)
|
|
27
|
+
return _default_stream_fn
|
pi_agent/tools.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Build agent tools from plain Python functions.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
@agent_tool
|
|
6
|
+
async def get_weather(city: str, units: Literal["celsius", "fahrenheit"] = "celsius") -> str:
|
|
7
|
+
\"\"\"Get the current weather.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
city: City name.
|
|
11
|
+
units: Temperature units.
|
|
12
|
+
\"\"\"
|
|
13
|
+
return f"Sunny in {city}"
|
|
14
|
+
|
|
15
|
+
The JSON Schema comes from the signature (``str``, ``int``, ``float``, ``bool``,
|
|
16
|
+
``list[...]``, ``dict``, ``Literal``, ``Enum``, ``X | None``, Pydantic models and
|
|
17
|
+
dataclasses), descriptions from a Google-style ``Args:`` docstring section.
|
|
18
|
+
Parameters named ``tool_call_id``, ``signal`` and ``on_update`` are injected
|
|
19
|
+
instead of exposed to the model. Return values are converted: ``str`` becomes a
|
|
20
|
+
text block, :class:`AgentToolResult` passes through, other values are sent as JSON.
|
|
21
|
+
Synchronous functions run in a worker thread.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import asyncio
|
|
27
|
+
import collections.abc
|
|
28
|
+
import dataclasses
|
|
29
|
+
import enum
|
|
30
|
+
import inspect
|
|
31
|
+
import json
|
|
32
|
+
import re
|
|
33
|
+
import types
|
|
34
|
+
import typing
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
from typing import Any, Literal, get_args, get_origin
|
|
37
|
+
|
|
38
|
+
from pi_ai import ImageContent, TextContent
|
|
39
|
+
from pi_ai.utils.schema import is_pydantic_model, parameters_schema
|
|
40
|
+
|
|
41
|
+
from .types import AgentTool, AgentToolResult, ToolExecutionMode
|
|
42
|
+
|
|
43
|
+
__all__ = ["agent_tool", "tool_from_function", "schema_from_type"]
|
|
44
|
+
|
|
45
|
+
_INJECTED = frozenset({"tool_call_id", "signal", "on_update"})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def schema_from_type(annotation: Any) -> dict[str, Any]:
|
|
49
|
+
"""JSON Schema for a Python type annotation."""
|
|
50
|
+
if annotation is inspect.Parameter.empty or annotation is Any:
|
|
51
|
+
return {}
|
|
52
|
+
origin = get_origin(annotation)
|
|
53
|
+
args = get_args(annotation)
|
|
54
|
+
if origin is typing.Annotated:
|
|
55
|
+
schema = schema_from_type(args[0])
|
|
56
|
+
for extra in args[1:]:
|
|
57
|
+
if isinstance(extra, str):
|
|
58
|
+
schema["description"] = extra
|
|
59
|
+
elif isinstance(extra, dict):
|
|
60
|
+
schema.update(extra)
|
|
61
|
+
return schema
|
|
62
|
+
if origin in (typing.Union, types.UnionType):
|
|
63
|
+
members = [arg for arg in args if arg is not type(None)]
|
|
64
|
+
nullable = len(members) != len(args)
|
|
65
|
+
if len(members) == 1:
|
|
66
|
+
schema = schema_from_type(members[0])
|
|
67
|
+
else:
|
|
68
|
+
schema = {"anyOf": [schema_from_type(member) for member in members]}
|
|
69
|
+
if nullable:
|
|
70
|
+
return {"anyOf": [schema, {"type": "null"}]} if schema else schema
|
|
71
|
+
return schema
|
|
72
|
+
if origin is Literal:
|
|
73
|
+
values = list(args)
|
|
74
|
+
if all(isinstance(value, str) for value in values):
|
|
75
|
+
return {"type": "string", "enum": values}
|
|
76
|
+
return {"enum": values}
|
|
77
|
+
# ``get_origin`` reports ``collections.abc`` classes for the ``typing`` aliases (``Sequence[int]``).
|
|
78
|
+
if origin in (list, tuple, set, frozenset, collections.abc.Sequence) or annotation in (list, tuple, set):
|
|
79
|
+
item = args[0] if args else Any
|
|
80
|
+
if origin is tuple and len(args) > 1 and args[1] is not Ellipsis:
|
|
81
|
+
return {"type": "array", "prefixItems": [schema_from_type(arg) for arg in args], "items": False}
|
|
82
|
+
return {"type": "array", "items": schema_from_type(item)}
|
|
83
|
+
if origin in (dict, collections.abc.Mapping) or annotation is dict:
|
|
84
|
+
value = args[1] if len(args) == 2 else Any
|
|
85
|
+
schema: dict[str, Any] = {"type": "object"}
|
|
86
|
+
value_schema = schema_from_type(value)
|
|
87
|
+
if value_schema:
|
|
88
|
+
schema["additionalProperties"] = value_schema
|
|
89
|
+
return schema
|
|
90
|
+
if annotation is str:
|
|
91
|
+
return {"type": "string"}
|
|
92
|
+
if annotation is bool:
|
|
93
|
+
return {"type": "boolean"}
|
|
94
|
+
if annotation is int:
|
|
95
|
+
return {"type": "integer"}
|
|
96
|
+
if annotation is float:
|
|
97
|
+
return {"type": "number"}
|
|
98
|
+
if annotation is type(None):
|
|
99
|
+
return {"type": "null"}
|
|
100
|
+
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
|
|
101
|
+
values = [member.value for member in annotation]
|
|
102
|
+
if all(isinstance(value, str) for value in values):
|
|
103
|
+
return {"type": "string", "enum": values}
|
|
104
|
+
return {"enum": values}
|
|
105
|
+
if is_pydantic_model(annotation):
|
|
106
|
+
return dict(parameters_schema(annotation))
|
|
107
|
+
if dataclasses.is_dataclass(annotation) and isinstance(annotation, type):
|
|
108
|
+
hints = typing.get_type_hints(annotation)
|
|
109
|
+
properties: dict[str, Any] = {}
|
|
110
|
+
required: list[str] = []
|
|
111
|
+
for item in dataclasses.fields(annotation):
|
|
112
|
+
properties[item.name] = schema_from_type(hints.get(item.name, Any))
|
|
113
|
+
if item.default is dataclasses.MISSING and item.default_factory is dataclasses.MISSING:
|
|
114
|
+
required.append(item.name)
|
|
115
|
+
result: dict[str, Any] = {"type": "object", "properties": properties}
|
|
116
|
+
if required:
|
|
117
|
+
result["required"] = required
|
|
118
|
+
return result
|
|
119
|
+
return {}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_ARGS_HEADER = re.compile(r"^\s*(Args|Arguments|Parameters):\s*$")
|
|
123
|
+
_SECTION_HEADER = re.compile(r"^\s*[A-Z][A-Za-z ]+:\s*$")
|
|
124
|
+
_ARG_LINE = re.compile(r"^\s*(\*{0,2}\w+)\s*(?:\(([^)]*)\))?\s*:\s*(.*)$")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_docstring(doc: str | None) -> tuple[str, dict[str, str]]:
|
|
128
|
+
if not doc:
|
|
129
|
+
return "", {}
|
|
130
|
+
lines = inspect.cleandoc(doc).splitlines()
|
|
131
|
+
summary: list[str] = []
|
|
132
|
+
params: dict[str, str] = {}
|
|
133
|
+
index = 0
|
|
134
|
+
while index < len(lines) and not _ARGS_HEADER.match(lines[index]):
|
|
135
|
+
if _SECTION_HEADER.match(lines[index]):
|
|
136
|
+
break
|
|
137
|
+
summary.append(lines[index])
|
|
138
|
+
index += 1
|
|
139
|
+
current: str | None = None
|
|
140
|
+
if index < len(lines) and _ARGS_HEADER.match(lines[index]):
|
|
141
|
+
index += 1
|
|
142
|
+
while index < len(lines):
|
|
143
|
+
line = lines[index]
|
|
144
|
+
if line.strip() and not line.startswith((" ", "\t")) and _SECTION_HEADER.match(line):
|
|
145
|
+
break
|
|
146
|
+
match = _ARG_LINE.match(line)
|
|
147
|
+
if match and (line.startswith((" ", "\t")) or current is None):
|
|
148
|
+
name = match.group(1).lstrip("*")
|
|
149
|
+
params[name] = match.group(3).strip()
|
|
150
|
+
current = name
|
|
151
|
+
elif current is not None and line.strip():
|
|
152
|
+
params[current] = f"{params[current]} {line.strip()}".strip()
|
|
153
|
+
index += 1
|
|
154
|
+
return "\n".join(summary).strip(), params
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _convert_argument(annotation: Any, value: Any) -> Any:
|
|
158
|
+
"""Convert a validated JSON value to its annotated type (enums, Pydantic models, dataclasses).
|
|
159
|
+
|
|
160
|
+
The schema advertises these types, so the function receives instances, not raw JSON.
|
|
161
|
+
"""
|
|
162
|
+
if value is None or annotation is inspect.Parameter.empty or annotation is Any:
|
|
163
|
+
return value
|
|
164
|
+
origin = get_origin(annotation)
|
|
165
|
+
args = get_args(annotation)
|
|
166
|
+
if origin is typing.Annotated:
|
|
167
|
+
return _convert_argument(args[0], value)
|
|
168
|
+
if origin in (typing.Union, types.UnionType):
|
|
169
|
+
members = [arg for arg in args if arg is not type(None)]
|
|
170
|
+
return _convert_argument(members[0], value) if len(members) == 1 else value
|
|
171
|
+
if origin in (list, tuple, set, frozenset, collections.abc.Sequence) and isinstance(value, list):
|
|
172
|
+
if origin is tuple and len(args) > 1 and args[1] is not Ellipsis:
|
|
173
|
+
return tuple(_convert_argument(arg, item) for arg, item in zip(args, value, strict=False))
|
|
174
|
+
items = [_convert_argument(args[0], item) for item in value] if args else list(value)
|
|
175
|
+
return origin(items) if origin in (tuple, set, frozenset) else items
|
|
176
|
+
if origin in (dict, collections.abc.Mapping) and isinstance(value, dict) and len(args) == 2:
|
|
177
|
+
return {key: _convert_argument(args[1], item) for key, item in value.items()}
|
|
178
|
+
if not isinstance(annotation, type):
|
|
179
|
+
return value
|
|
180
|
+
if issubclass(annotation, enum.Enum):
|
|
181
|
+
return value if isinstance(value, annotation) else annotation(value)
|
|
182
|
+
if is_pydantic_model(annotation) and isinstance(value, dict):
|
|
183
|
+
return annotation.model_validate(value)
|
|
184
|
+
if dataclasses.is_dataclass(annotation) and isinstance(value, dict):
|
|
185
|
+
hints = typing.get_type_hints(annotation)
|
|
186
|
+
return annotation(**{key: _convert_argument(hints.get(key, Any), item) for key, item in value.items()})
|
|
187
|
+
return value
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _to_result(value: Any) -> AgentToolResult:
|
|
191
|
+
if isinstance(value, AgentToolResult):
|
|
192
|
+
return value
|
|
193
|
+
if value is None:
|
|
194
|
+
return AgentToolResult([], None)
|
|
195
|
+
if isinstance(value, str):
|
|
196
|
+
return AgentToolResult([TextContent(value)], None)
|
|
197
|
+
if isinstance(value, (TextContent, ImageContent)):
|
|
198
|
+
return AgentToolResult([value], None)
|
|
199
|
+
if isinstance(value, list) and value and all(isinstance(item, (TextContent, ImageContent)) for item in value):
|
|
200
|
+
return AgentToolResult(list(value), None)
|
|
201
|
+
from pi_ai.serialization import to_json_value
|
|
202
|
+
|
|
203
|
+
data = to_json_value(value)
|
|
204
|
+
return AgentToolResult([TextContent(json.dumps(data, ensure_ascii=False))], data)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def tool_from_function(
|
|
208
|
+
fn: Callable[..., Any],
|
|
209
|
+
*,
|
|
210
|
+
name: str | None = None,
|
|
211
|
+
description: str | None = None,
|
|
212
|
+
label: str | None = None,
|
|
213
|
+
parameters: Any = None,
|
|
214
|
+
execution_mode: ToolExecutionMode | None = None,
|
|
215
|
+
) -> AgentTool:
|
|
216
|
+
"""Create an :class:`AgentTool` from a function (see the module docstring)."""
|
|
217
|
+
signature = inspect.signature(fn)
|
|
218
|
+
try:
|
|
219
|
+
hints = typing.get_type_hints(fn, include_extras=True)
|
|
220
|
+
except Exception:
|
|
221
|
+
hints = {}
|
|
222
|
+
summary, param_docs = _parse_docstring(fn.__doc__)
|
|
223
|
+
exposed = [param for param in signature.parameters.values() if param.name not in _INJECTED]
|
|
224
|
+
injected = {param.name for param in signature.parameters.values() if param.name in _INJECTED}
|
|
225
|
+
|
|
226
|
+
single_model = (
|
|
227
|
+
parameters is None
|
|
228
|
+
and len(exposed) == 1
|
|
229
|
+
and is_pydantic_model(hints.get(exposed[0].name))
|
|
230
|
+
and exposed[0].kind is not inspect.Parameter.VAR_KEYWORD
|
|
231
|
+
)
|
|
232
|
+
if parameters is None and not single_model:
|
|
233
|
+
properties: dict[str, Any] = {}
|
|
234
|
+
required: list[str] = []
|
|
235
|
+
for param in exposed:
|
|
236
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
237
|
+
continue
|
|
238
|
+
schema = schema_from_type(hints.get(param.name, param.annotation))
|
|
239
|
+
if param.name in param_docs and "description" not in schema:
|
|
240
|
+
schema["description"] = param_docs[param.name]
|
|
241
|
+
if param.default is inspect.Parameter.empty:
|
|
242
|
+
required.append(param.name)
|
|
243
|
+
elif param.default is not None and isinstance(param.default, (str, int, float, bool)):
|
|
244
|
+
schema.setdefault(
|
|
245
|
+
"default", param.default.value if isinstance(param.default, enum.Enum) else param.default
|
|
246
|
+
)
|
|
247
|
+
properties[param.name] = schema
|
|
248
|
+
parameters = {"type": "object", "properties": properties}
|
|
249
|
+
if required:
|
|
250
|
+
parameters["required"] = required
|
|
251
|
+
elif single_model:
|
|
252
|
+
parameters = hints[exposed[0].name]
|
|
253
|
+
|
|
254
|
+
is_async = inspect.iscoroutinefunction(fn)
|
|
255
|
+
model_param = exposed[0].name if single_model else None
|
|
256
|
+
|
|
257
|
+
async def execute(tool_call_id: str, params: Any, signal: Any = None, on_update: Any = None) -> AgentToolResult:
|
|
258
|
+
if model_param is not None:
|
|
259
|
+
kwargs: dict[str, Any] = {model_param: params}
|
|
260
|
+
elif isinstance(params, dict):
|
|
261
|
+
kwargs = dict(params)
|
|
262
|
+
for param in exposed:
|
|
263
|
+
if param.name in kwargs:
|
|
264
|
+
kwargs[param.name] = _convert_argument(hints.get(param.name, Any), kwargs[param.name])
|
|
265
|
+
else:
|
|
266
|
+
kwargs = {"params": params}
|
|
267
|
+
if "tool_call_id" in injected:
|
|
268
|
+
kwargs["tool_call_id"] = tool_call_id
|
|
269
|
+
if "signal" in injected:
|
|
270
|
+
kwargs["signal"] = signal
|
|
271
|
+
if "on_update" in injected:
|
|
272
|
+
kwargs["on_update"] = on_update
|
|
273
|
+
if is_async:
|
|
274
|
+
value = await fn(**kwargs)
|
|
275
|
+
else:
|
|
276
|
+
value = await asyncio.to_thread(fn, **kwargs)
|
|
277
|
+
return _to_result(value)
|
|
278
|
+
|
|
279
|
+
tool_name = name or fn.__name__
|
|
280
|
+
return AgentTool(
|
|
281
|
+
tool_name,
|
|
282
|
+
description if description is not None else summary,
|
|
283
|
+
parameters,
|
|
284
|
+
execute=execute,
|
|
285
|
+
label=label or tool_name,
|
|
286
|
+
execution_mode=execution_mode,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@typing.overload
|
|
291
|
+
def agent_tool(fn: Callable[..., Any], /) -> AgentTool: ...
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@typing.overload
|
|
295
|
+
def agent_tool(
|
|
296
|
+
*,
|
|
297
|
+
name: str | None = None,
|
|
298
|
+
description: str | None = None,
|
|
299
|
+
label: str | None = None,
|
|
300
|
+
parameters: Any = None,
|
|
301
|
+
execution_mode: ToolExecutionMode | None = None,
|
|
302
|
+
) -> Callable[[Callable[..., Any]], AgentTool]: ...
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def agent_tool(
|
|
306
|
+
fn: Callable[..., Any] | None = None,
|
|
307
|
+
/,
|
|
308
|
+
*,
|
|
309
|
+
name: str | None = None,
|
|
310
|
+
description: str | None = None,
|
|
311
|
+
label: str | None = None,
|
|
312
|
+
parameters: Any = None,
|
|
313
|
+
execution_mode: ToolExecutionMode | None = None,
|
|
314
|
+
) -> Any:
|
|
315
|
+
"""Decorator form of :func:`tool_from_function` (usable with or without arguments)."""
|
|
316
|
+
|
|
317
|
+
def wrap(function: Callable[..., Any]) -> AgentTool:
|
|
318
|
+
return tool_from_function(
|
|
319
|
+
function,
|
|
320
|
+
name=name,
|
|
321
|
+
description=description,
|
|
322
|
+
label=label,
|
|
323
|
+
parameters=parameters,
|
|
324
|
+
execution_mode=execution_mode,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
return wrap(fn) if fn is not None else wrap
|