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
@@ -0,0 +1,449 @@
1
+ # pyright: reportAttributeAccessIssue=false, reportArgumentType=false
2
+ # Converters branch on ``role``/``type`` discriminators held in locals, which pyright does not narrow.
3
+ """pi-messages API implementation.
4
+
5
+ Streams pi's own message protocol directly to a backend: the request is a single
6
+ POST of ``{model, context, options}`` to ``<base_url>/messages``, the response is
7
+ an SSE stream of serialized assistant-message events plus a terminal
8
+ ``done``/``error`` event. This is the wire protocol spoken by the Radius gateway,
9
+ but any backend implementing it can be used, e.g. via a models.json custom
10
+ provider with ``"api": "pi-messages"``.
11
+
12
+ ``context`` is sent in pi's camelCase JSON (:func:`pi_ai.serialization.to_json_value`),
13
+ so the wire format is the one the TypeScript implementation produces.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from collections.abc import AsyncIterator, Mapping
20
+ from typing import Any, Literal, TypeAlias, TypedDict
21
+
22
+ import httpx
23
+
24
+ from ..abort import abort_scope
25
+ from ..event_stream import AssistantMessageEventStream
26
+ from ..serialization import from_dict, snake_case, to_json_value
27
+ from ..types import (
28
+ AssistantMessage,
29
+ AssistantMessageEvent,
30
+ CacheRetention,
31
+ DoneEvent,
32
+ ErrorEvent,
33
+ Model,
34
+ ProviderResponse,
35
+ StartEvent,
36
+ StreamOptions,
37
+ TextContent,
38
+ TextDeltaEvent,
39
+ TextEndEvent,
40
+ TextStartEvent,
41
+ ThinkingContent,
42
+ ThinkingDeltaEvent,
43
+ ThinkingEndEvent,
44
+ ThinkingLevel,
45
+ ThinkingStartEvent,
46
+ ToolCall,
47
+ ToolCallDeltaEvent,
48
+ ToolCallEndEvent,
49
+ ToolCallStartEvent,
50
+ TranscriptContext,
51
+ Usage,
52
+ now_ms,
53
+ )
54
+ from ..utils import fastjson
55
+ from ..utils.callbacks import maybe_await
56
+ from ..utils.diagnostics import (
57
+ AssistantMessageDiagnostic,
58
+ append_assistant_message_diagnostic,
59
+ create_assistant_message_diagnostic,
60
+ )
61
+ from ..utils.http import ProviderHttpError, build_headers, send_request
62
+ from ..utils.json_parse import StreamingJsonParser
63
+ from ..utils.misc import get_provider_env_value, headers_to_record, provider_headers_to_record
64
+ from ..utils.text import append_text, append_thinking
65
+
66
+ __all__ = [
67
+ "PiMessagesOptions",
68
+ "PiMessagesToolChoice",
69
+ "PiMessagesRewriteImpact",
70
+ "PiMessagesEvent",
71
+ "PiMessagesResponseError",
72
+ "stream",
73
+ "stream_simple",
74
+ ]
75
+
76
+ PiMessagesToolChoice: TypeAlias = "Literal['auto', 'none', 'required'] | dict[str, Any]"
77
+
78
+
79
+ class PiMessagesOptions(StreamOptions, total=False):
80
+ reasoning: ThinkingLevel
81
+ #: ``"auto" | "none" | "required"`` or ``{"type": "function", "function": {"name": ...}}``.
82
+ tool_choice: PiMessagesToolChoice
83
+ #: Ask the backend for debug metadata (e.g. routing response headers).
84
+ debug: bool
85
+
86
+
87
+ class PiMessagesRewriteImpact(TypedDict):
88
+ """Impact summary of a server-side message rewrite (e.g. a gateway policy). Wire JSON, camelCase keys."""
89
+
90
+ policyId: str
91
+ policyVersion: int
92
+ changed: bool
93
+ tokenCountChange: int
94
+ messageCountChange: int
95
+ systemPromptChanged: bool
96
+
97
+
98
+ #: Serialized assistant-message event as sent by a pi-messages backend (camelCase wire JSON):
99
+ #:
100
+ #: * ``{"type": "start"}``
101
+ #: * ``{"type": "text_start" | "thinking_start", "contentIndex": n}``
102
+ #: * ``{"type": "text_delta" | "thinking_delta" | "toolcall_delta", "contentIndex": n, "delta": str}``
103
+ #: * ``{"type": "text_end", "contentIndex": n, "content": str, "contentSignature"?: str}``
104
+ #: * ``{"type": "thinking_end", "contentIndex": n, "content": str, "contentSignature"?: str, "redacted"?: bool}``
105
+ #: * ``{"type": "toolcall_start", "contentIndex": n, "id": str, "toolName": str}``
106
+ #: * ``{"type": "toolcall_end", "contentIndex": n, "toolCall": {...}}``
107
+ #: * ``{"type": "done", "reason": "stop" | "length" | "toolUse", "usage": {...}, "responseId"?: str,
108
+ #: "providerThinkingLevel"?: str, "rewrite"?: PiMessagesRewriteImpact}``
109
+ #: * ``{"type": "error", "reason": "aborted" | "error", "usage": {...}, "errorMessage"?: str, "responseId"?: str,
110
+ #: "providerThinkingLevel"?: str, "rewrite"?: PiMessagesRewriteImpact}``
111
+ PiMessagesEvent: TypeAlias = dict[str, Any]
112
+
113
+ _MAX_DIAGNOSTIC_STRING = 8192
114
+
115
+
116
+ class PiMessagesResponseError(Exception):
117
+ """A non-2xx pi-messages response. ``diagnostic_details`` is attached to the error message's diagnostics."""
118
+
119
+ def __init__(self, message: str, code: str | None, diagnostic_details: dict[str, Any]) -> None:
120
+ super().__init__(message)
121
+ self.code = code
122
+ self.diagnostic_details = diagnostic_details
123
+
124
+
125
+ def _parse_error_body(body: str) -> dict[str, Any] | None:
126
+ try:
127
+ parsed = json.loads(body)
128
+ except ValueError:
129
+ return None
130
+ if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
131
+ return parsed
132
+ return None
133
+
134
+
135
+ def _truncate_diagnostic_string(value: str) -> str:
136
+ return f"{value[:_MAX_DIAGNOSTIC_STRING]}…" if len(value) > _MAX_DIAGNOSTIC_STRING else value
137
+
138
+
139
+ def _create_response_error(model: Model, url: str, status: int, body: str) -> PiMessagesResponseError:
140
+ error_body = _parse_error_body(body)
141
+ error = error_body["error"] if error_body is not None else None
142
+ message = error.get("message") if error is not None else None
143
+ code = error.get("code") if error is not None else None
144
+ message = message if isinstance(message, str) else None
145
+ code = code if isinstance(code, str) else None
146
+ status_text = httpx.codes.get_reason_phrase(status)
147
+ suffix = message if message is not None else body
148
+ code_suffix = f" ({code})" if code else ""
149
+ details: dict[str, Any] = {
150
+ "version": 1,
151
+ "provider": model.provider,
152
+ "model": model.id,
153
+ "url": url,
154
+ "status": status,
155
+ "statusText": status_text,
156
+ }
157
+ if error is not None:
158
+ details["error"] = error
159
+ else:
160
+ details["body"] = _truncate_diagnostic_string(body)
161
+ details["timestampMs"] = now_ms()
162
+ return PiMessagesResponseError(f"{status} {status_text}: {suffix}{code_suffix}", code, details)
163
+
164
+
165
+ def _append_rewrite_diagnostic(message: AssistantMessage, rewrite: Any) -> None:
166
+ if not rewrite:
167
+ return
168
+ append_assistant_message_diagnostic(
169
+ message,
170
+ AssistantMessageDiagnostic(type="pi_messages_rewrite", timestamp=now_ms(), details=dict(rewrite)),
171
+ )
172
+
173
+
174
+ def _usage(value: Any) -> Usage:
175
+ return from_dict(Usage, value) if isinstance(value, Mapping) else Usage()
176
+
177
+
178
+ #: Wire ``toolCall`` keys that are not attributes of :class:`ToolCall`.
179
+ _TOOL_CALL_SKIP = frozenset({"type"})
180
+ _TOOL_CALL_FIELDS = frozenset(ToolCall.__slots__) # type: ignore[attr-defined]
181
+
182
+
183
+ class _EventConverter:
184
+ """Folds wire events into one live partial message and returns the matching pi event."""
185
+
186
+ __slots__ = ("_blocks", "_tool_json", "partial")
187
+
188
+ def __init__(self, model: Model) -> None:
189
+ self.partial = AssistantMessage(
190
+ [],
191
+ api=model.api,
192
+ provider=model.provider,
193
+ model=model.id,
194
+ usage=Usage(),
195
+ stop_reason="pending",
196
+ timestamp=now_ms(),
197
+ )
198
+ # Blocks by wire content index (pi's content array may be addressed sparsely).
199
+ self._blocks: dict[int, Any] = {}
200
+ self._tool_json: dict[int, StreamingJsonParser] = {}
201
+
202
+ def _set_block(self, index: int, block: Any) -> None:
203
+ content = self.partial.content
204
+ if 0 <= index < len(content):
205
+ content[index] = block
206
+ else:
207
+ content.append(block)
208
+ self._blocks[index] = block
209
+
210
+ def _finish(self, event: PiMessagesEvent) -> None:
211
+ partial = self.partial
212
+ partial.stop_reason = event.get("reason") # type: ignore[assignment]
213
+ partial.usage = _usage(event.get("usage"))
214
+ partial.response_id = event.get("responseId")
215
+ provider_thinking_level = event.get("providerThinkingLevel")
216
+ if provider_thinking_level is not None:
217
+ partial.provider_thinking_level = provider_thinking_level
218
+ _append_rewrite_diagnostic(partial, event.get("rewrite"))
219
+
220
+ def convert(self, event: PiMessagesEvent) -> AssistantMessageEvent | None:
221
+ partial = self.partial
222
+ kind = event.get("type")
223
+ if kind == "done":
224
+ self._finish(event)
225
+ return DoneEvent(event.get("reason"), partial) # type: ignore[arg-type]
226
+ if kind == "error":
227
+ self._finish(event)
228
+ partial.error_message = event.get("errorMessage")
229
+ return ErrorEvent(event.get("reason"), partial) # type: ignore[arg-type]
230
+ if kind == "start":
231
+ return StartEvent(partial)
232
+ index = event.get("contentIndex")
233
+ if kind == "text_start":
234
+ self._set_block(index, TextContent(""))
235
+ return TextStartEvent(index, partial)
236
+ if kind == "text_delta":
237
+ delta = event["delta"]
238
+ append_text(self._blocks[index], delta)
239
+ return TextDeltaEvent(index, delta, partial)
240
+ if kind == "text_end":
241
+ block = self._blocks[index]
242
+ block.text = event["content"]
243
+ block.text_signature = event.get("contentSignature")
244
+ return TextEndEvent(index, event["content"], partial)
245
+ if kind == "thinking_start":
246
+ self._set_block(index, ThinkingContent(""))
247
+ return ThinkingStartEvent(index, partial)
248
+ if kind == "thinking_delta":
249
+ delta = event["delta"]
250
+ append_thinking(self._blocks[index], delta)
251
+ return ThinkingDeltaEvent(index, delta, partial)
252
+ if kind == "thinking_end":
253
+ block = self._blocks[index]
254
+ block.thinking = event["content"]
255
+ block.thinking_signature = event.get("contentSignature")
256
+ block.redacted = event.get("redacted")
257
+ return ThinkingEndEvent(index, event["content"], partial)
258
+ if kind == "toolcall_start":
259
+ self._set_block(index, ToolCall(event.get("id"), event.get("toolName"), {})) # type: ignore[arg-type]
260
+ self._tool_json[index] = StreamingJsonParser()
261
+ return ToolCallStartEvent(index, partial)
262
+ if kind == "toolcall_delta":
263
+ delta = event["delta"]
264
+ parser = self._tool_json.get(index)
265
+ if parser is None:
266
+ parser = self._tool_json[index] = StreamingJsonParser()
267
+ parser.feed(delta)
268
+ self._blocks[index].arguments = parser.value()
269
+ return ToolCallDeltaEvent(index, delta, partial)
270
+ if kind == "toolcall_end":
271
+ block = self._blocks[index]
272
+ for key, value in (event.get("toolCall") or {}).items():
273
+ if key in _TOOL_CALL_SKIP:
274
+ continue
275
+ name = snake_case(key)
276
+ if name in _TOOL_CALL_FIELDS:
277
+ setattr(block, name, value)
278
+ self._tool_json.pop(index, None) # pyright: ignore[reportCallIssue]
279
+ return ToolCallEndEvent(index, block, partial)
280
+ # Unknown event types have no pi event to map to.
281
+ return None
282
+
283
+
284
+ def _parse_event(raw: str) -> PiMessagesEvent | None:
285
+ data: str | None = None
286
+ for line in raw.split("\n"):
287
+ if line.startswith("data:"):
288
+ data = line[5:].strip()
289
+ break
290
+ if not data or data == "[DONE]":
291
+ return None
292
+ return fastjson.loads(data)
293
+
294
+
295
+ async def _read_events(response: httpx.Response) -> AsyncIterator[PiMessagesEvent]:
296
+ buffer = ""
297
+ async for chunk in response.aiter_text():
298
+ buffer = (buffer + chunk).replace("\r\n", "\n")
299
+ split = buffer.find("\n\n")
300
+ while split != -1:
301
+ event = _parse_event(buffer[:split])
302
+ if event is not None:
303
+ yield event
304
+ buffer = buffer[split + 2 :]
305
+ split = buffer.find("\n\n")
306
+ if buffer.strip():
307
+ event = _parse_event(buffer)
308
+ if event is not None:
309
+ yield event
310
+
311
+
312
+ def _create_error_event(model: Model, error: Exception, aborted: bool) -> ErrorEvent:
313
+ reason: Literal["aborted", "error"] = "aborted" if aborted else "error"
314
+ message = AssistantMessage(
315
+ [],
316
+ api=model.api,
317
+ provider=model.provider,
318
+ model=model.id,
319
+ usage=Usage(),
320
+ stop_reason=reason,
321
+ error_message=str(error),
322
+ timestamp=now_ms(),
323
+ )
324
+ if not aborted and isinstance(error, PiMessagesResponseError):
325
+ append_assistant_message_diagnostic(
326
+ message,
327
+ create_assistant_message_diagnostic("pi_messages_response_failure", error, error.diagnostic_details),
328
+ )
329
+ return ErrorEvent(reason, message)
330
+
331
+
332
+ def _resolve_cache_retention(
333
+ cache_retention: CacheRetention | None, env: Mapping[str, str] | None
334
+ ) -> CacheRetention | None:
335
+ if cache_retention:
336
+ return cache_retention
337
+ # Backend defaults apply when unset; only the legacy env opt-in is mapped.
338
+ return "long" if get_provider_env_value("PI_CACHE_RETENTION", env) == "long" else None
339
+
340
+
341
+ def _messages_url(model: Model, debug: bool) -> str:
342
+ url = f"{model.base_url.rstrip('/')}/messages"
343
+ if debug:
344
+ url = str(httpx.URL(url).copy_set_param("debug", "1"))
345
+ return url
346
+
347
+
348
+ async def _notify_response(options: Mapping[str, Any], model: Model, status: int, headers: Any) -> None:
349
+ on_response = options.get("on_response")
350
+ if on_response is not None:
351
+ await maybe_await(on_response(ProviderResponse(status, headers_to_record(headers or {})), model))
352
+
353
+
354
+ def stream(
355
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
356
+ ) -> AssistantMessageEventStream:
357
+ """Stream a response from a pi-messages backend (``POST <base_url>/messages``)."""
358
+ opts: dict[str, Any] = dict(options or {})
359
+ out = AssistantMessageEventStream()
360
+ out.start(_run(model, context, opts, out, _EventConverter(model)))
361
+ return out
362
+
363
+
364
+ async def _run(
365
+ model: Model,
366
+ context: TranscriptContext,
367
+ options: dict[str, Any],
368
+ out: AssistantMessageEventStream,
369
+ converter: _EventConverter,
370
+ ) -> None:
371
+ signal = options.get("signal")
372
+ try:
373
+ async with abort_scope(signal):
374
+ api_key = options.get("api_key")
375
+ if not api_key:
376
+ raise ValueError(f'No API key provided for provider "{model.provider}"')
377
+
378
+ url = _messages_url(model, bool(options.get("debug")))
379
+ request_options = {
380
+ "temperature": options.get("temperature"),
381
+ "maxTokens": options.get("max_tokens"),
382
+ "reasoning": options.get("reasoning"),
383
+ "cacheRetention": _resolve_cache_retention(options.get("cache_retention"), options.get("env")),
384
+ "sessionId": options.get("session_id"),
385
+ "toolChoice": options.get("tool_choice"),
386
+ }
387
+ payload: Any = {
388
+ "model": model.id,
389
+ "context": to_json_value(context),
390
+ "options": {key: value for key, value in request_options.items() if value is not None},
391
+ }
392
+ on_payload = options.get("on_payload")
393
+ if on_payload is not None:
394
+ next_payload = await maybe_await(on_payload(payload, model))
395
+ if next_payload is not None:
396
+ payload = next_payload
397
+
398
+ headers = build_headers(
399
+ {
400
+ "authorization": f"Bearer {api_key}",
401
+ "accept": "text/event-stream",
402
+ "content-type": "application/json",
403
+ },
404
+ provider_headers_to_record(options.get("headers")),
405
+ )
406
+ try:
407
+ # The backend owns retries: the request is sent once, like upstream's plain fetch.
408
+ response = await send_request(
409
+ "POST",
410
+ url,
411
+ headers=headers,
412
+ body=payload,
413
+ options={**options, "max_retries": 0},
414
+ error_style="raw",
415
+ )
416
+ except ProviderHttpError as error:
417
+ if error.status is None:
418
+ raise
419
+ await _notify_response(options, model, error.status, error.headers)
420
+ raise _create_response_error(model, url, error.status, error.raw_body or "") from error
421
+ try:
422
+ await _notify_response(options, model, response.status_code, response.headers)
423
+ if not response.is_success:
424
+ body = (await response.aread()).decode("utf-8", "replace")
425
+ raise _create_response_error(model, url, response.status_code, body)
426
+ on_event = options.get("on_provider_stream_event")
427
+ async for wire_event in _read_events(response):
428
+ if on_event is not None:
429
+ await maybe_await(on_event(wire_event, model))
430
+ event = converter.convert(wire_event)
431
+ if event is None:
432
+ continue
433
+ out.push(event)
434
+ if event.type == "done" or event.type == "error":
435
+ out.end()
436
+ return
437
+ finally:
438
+ await response.aclose()
439
+ raise RuntimeError(f"{model.provider} stream ended without a terminal event")
440
+ except Exception as error:
441
+ out.push(_create_error_event(model, error, signal is not None and signal.aborted))
442
+ out.end()
443
+
444
+
445
+ def stream_simple(
446
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None
447
+ ) -> AssistantMessageEventStream:
448
+ """Simple options pass straight through: ``reasoning``, ``tool_choice`` and ``debug`` are forwarded as is."""
449
+ return stream(model, context, options)
pi_ai/api/registry.py ADDED
@@ -0,0 +1,103 @@
1
+ """Lazy wrappers for the built-in API implementations.
2
+
3
+ Provider factories reference these instead of importing implementation modules,
4
+ so an implementation loads only on the first request to a model of that API
5
+ (the analog of pi's ``api/<id>.lazy.ts`` modules).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .lazy import LazyApi, LazyOperation
11
+
12
+ __all__ = [
13
+ "API_MODULES",
14
+ "anthropic_messages_api",
15
+ "openai_completions_api",
16
+ "openai_responses_api",
17
+ "azure_openai_responses_api",
18
+ "openai_codex_responses_api",
19
+ "google_generative_ai_api",
20
+ "google_vertex_api",
21
+ "mistral_conversations_api",
22
+ "bedrock_converse_stream_api",
23
+ "pi_messages_api",
24
+ "openrouter_images_api",
25
+ "typesafe_system_one_api",
26
+ "cloudflare_workers_ai_system_one_api",
27
+ "llama_cpp_classify_api",
28
+ "openai_decisions_api",
29
+ ]
30
+
31
+ #: Chat API id -> implementation module.
32
+ API_MODULES: dict[str, str] = {
33
+ "anthropic-messages": "pi_ai.api.anthropic_messages",
34
+ "openai-completions": "pi_ai.api.openai_completions",
35
+ "openai-responses": "pi_ai.api.openai_responses",
36
+ "azure-openai-responses": "pi_ai.api.azure_openai_responses",
37
+ "openai-codex-responses": "pi_ai.api.openai_codex_responses",
38
+ "google-generative-ai": "pi_ai.api.google_generative_ai",
39
+ "google-vertex": "pi_ai.api.google_vertex",
40
+ "mistral-conversations": "pi_ai.api.mistral_conversations",
41
+ "bedrock-converse-stream": "pi_ai.api.bedrock_converse_stream",
42
+ "pi-messages": "pi_ai.api.pi_messages",
43
+ }
44
+
45
+
46
+ def anthropic_messages_api() -> LazyApi:
47
+ return LazyApi(API_MODULES["anthropic-messages"])
48
+
49
+
50
+ def openai_completions_api() -> LazyApi:
51
+ return LazyApi(API_MODULES["openai-completions"])
52
+
53
+
54
+ def openai_responses_api() -> LazyApi:
55
+ return LazyApi(API_MODULES["openai-responses"])
56
+
57
+
58
+ def azure_openai_responses_api() -> LazyApi:
59
+ return LazyApi(API_MODULES["azure-openai-responses"])
60
+
61
+
62
+ def openai_codex_responses_api() -> LazyApi:
63
+ return LazyApi(API_MODULES["openai-codex-responses"])
64
+
65
+
66
+ def google_generative_ai_api() -> LazyApi:
67
+ return LazyApi(API_MODULES["google-generative-ai"])
68
+
69
+
70
+ def google_vertex_api() -> LazyApi:
71
+ return LazyApi(API_MODULES["google-vertex"])
72
+
73
+
74
+ def mistral_conversations_api() -> LazyApi:
75
+ return LazyApi(API_MODULES["mistral-conversations"])
76
+
77
+
78
+ def bedrock_converse_stream_api() -> LazyApi:
79
+ return LazyApi(API_MODULES["bedrock-converse-stream"])
80
+
81
+
82
+ def pi_messages_api() -> LazyApi:
83
+ return LazyApi(API_MODULES["pi-messages"])
84
+
85
+
86
+ def openrouter_images_api() -> LazyOperation:
87
+ return LazyOperation("pi_ai.api.openrouter_images", "generate_images")
88
+
89
+
90
+ def typesafe_system_one_api() -> LazyOperation:
91
+ return LazyOperation("pi_ai.api.typesafe_system_one", "classify")
92
+
93
+
94
+ def cloudflare_workers_ai_system_one_api() -> LazyOperation:
95
+ return LazyOperation("pi_ai.api.cloudflare_workers_ai_system_one", "classify")
96
+
97
+
98
+ def llama_cpp_classify_api() -> LazyOperation:
99
+ return LazyOperation("pi_ai.api.llama_cpp_classify", "classify")
100
+
101
+
102
+ def openai_decisions_api() -> LazyOperation:
103
+ return LazyOperation("pi_ai.api.openai_decisions", "classify")
@@ -0,0 +1,119 @@
1
+ """Shared option handling for ``stream_simple`` implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from ..types import Model, TranscriptContext
9
+ from ..utils.estimate import estimate_context_tokens
10
+
11
+ __all__ = [
12
+ "CONTEXT_SAFETY_TOKENS",
13
+ "MIN_ANSWER_TOKENS",
14
+ "DEFAULT_THINKING_BUDGETS",
15
+ "clamp_max_tokens_to_context",
16
+ "resolve_sampling_params",
17
+ "build_base_options",
18
+ "clamp_reasoning",
19
+ "thinking_budget_for_level",
20
+ "clamp_thinking_budget_to_answer_room",
21
+ "adjust_max_tokens_for_thinking",
22
+ ]
23
+
24
+ CONTEXT_SAFETY_TOKENS = 4096
25
+ _MIN_MAX_TOKENS = 1
26
+ #: Tokens always left for the answer when a thinking budget shares the response ceiling.
27
+ MIN_ANSWER_TOKENS = 1024
28
+ DEFAULT_THINKING_BUDGETS: dict[str, int] = {"minimal": 1024, "low": 2048, "medium": 8192, "high": 16384}
29
+
30
+ _BASE_OPTION_KEYS = (
31
+ "temperature",
32
+ "signal",
33
+ "http_client",
34
+ "transport",
35
+ "cache_retention",
36
+ "session_id",
37
+ "headers",
38
+ "on_payload",
39
+ "on_response",
40
+ "on_provider_stream_event",
41
+ "timeout_ms",
42
+ "websocket_connect_timeout_ms",
43
+ "max_retries",
44
+ "max_retry_delay_ms",
45
+ "metadata",
46
+ "env",
47
+ )
48
+
49
+
50
+ def clamp_max_tokens_to_context(model: Model, context: TranscriptContext, max_tokens: int) -> int:
51
+ if model.context_window <= 0:
52
+ return max(_MIN_MAX_TOKENS, max_tokens)
53
+ available = model.context_window - estimate_context_tokens(context).tokens - CONTEXT_SAFETY_TOKENS
54
+ return min(max_tokens, max(_MIN_MAX_TOKENS, available))
55
+
56
+
57
+ def resolve_sampling_params(
58
+ model: Model, thinking_level: str, request_params: Mapping[str, Any] | None = None
59
+ ) -> dict[str, Any] | None:
60
+ from ..models import clamp_thinking_level
61
+
62
+ effective = clamp_thinking_level(model, thinking_level) # type: ignore[arg-type]
63
+ level_params = (model.sampling_params_by_thinking_level or {}).get(effective)
64
+ if not model.sampling_params and not level_params and not request_params:
65
+ return None
66
+ return {**(model.sampling_params or {}), **(level_params or {}), **(request_params or {})}
67
+
68
+
69
+ def build_base_options(
70
+ model: Model, context: TranscriptContext, options: Mapping[str, Any] | None = None, api_key: str | None = None
71
+ ) -> dict[str, Any]:
72
+ """Translate simple options into the API-agnostic stream options."""
73
+ options = options or {}
74
+ base: dict[str, Any] = {key: options[key] for key in _BASE_OPTION_KEYS if options.get(key) is not None}
75
+ sampling = resolve_sampling_params(model, options.get("reasoning") or "off", options.get("sampling_params"))
76
+ if sampling is not None:
77
+ base["sampling_params"] = sampling
78
+ max_tokens = options.get("max_tokens")
79
+ base["max_tokens"] = clamp_max_tokens_to_context(
80
+ model, context, max_tokens if max_tokens is not None else model.max_tokens
81
+ )
82
+ key = api_key or options.get("api_key")
83
+ if key:
84
+ base["api_key"] = key
85
+ return base
86
+
87
+
88
+ def clamp_reasoning(effort: str | None) -> str | None:
89
+ return "high" if effort in ("xhigh", "max") else effort
90
+
91
+
92
+ def thinking_budget_for_level(reasoning_level: str, custom_budgets: Mapping[str, int] | None = None) -> int:
93
+ budgets = {**DEFAULT_THINKING_BUDGETS, **(custom_budgets or {})}
94
+ level = clamp_reasoning(reasoning_level) or "medium"
95
+ return budgets[level]
96
+
97
+
98
+ def clamp_thinking_budget_to_answer_room(thinking_budget: int, ceiling: int) -> int:
99
+ """Cap a thinking budget so at least ``MIN_ANSWER_TOKENS`` remain under a shared ceiling."""
100
+ return min(thinking_budget, max(0, ceiling - MIN_ANSWER_TOKENS))
101
+
102
+
103
+ def adjust_max_tokens_for_thinking(
104
+ base_max_tokens: int | None,
105
+ model_max_tokens: int,
106
+ reasoning_level: str,
107
+ custom_budgets: Mapping[str, int] | None = None,
108
+ ) -> tuple[int, int]:
109
+ """Return ``(max_tokens, thinking_budget)`` with thinking fitted inside the response ceiling.
110
+
111
+ ``base_max_tokens=None`` means no explicit caller cap: use the model cap.
112
+ """
113
+ thinking_budget = thinking_budget_for_level(reasoning_level, custom_budgets)
114
+ max_tokens = (
115
+ model_max_tokens if base_max_tokens is None else min(base_max_tokens + thinking_budget, model_max_tokens)
116
+ )
117
+ if max_tokens <= thinking_budget:
118
+ thinking_budget = clamp_thinking_budget_to_answer_room(thinking_budget, max_tokens)
119
+ return max_tokens, thinking_budget