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.
Files changed (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. 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