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,424 @@
1
+ """Tolerant JSON parsing for streamed tool-call arguments.
2
+
3
+ ``parse_streaming_json`` mirrors pi's helper (best-effort parse of a JSON prefix).
4
+ :class:`StreamingJsonParser` is an incremental variant used by the provider
5
+ adapters: it keeps the lexer state between deltas, so each delta costs time
6
+ proportional to the delta (plus one C-level ``json.loads``) instead of rescanning
7
+ the whole argument text in Python.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from typing import Any
15
+
16
+ __all__ = [
17
+ "repair_json",
18
+ "parse_json_with_repair",
19
+ "parse_tool_call_arguments",
20
+ "parse_streaming_json",
21
+ "complete_partial_json",
22
+ "StreamingJsonParser",
23
+ ]
24
+
25
+ _VALID_ESCAPES = frozenset('"\\/bfnrt')
26
+ _HEX4 = re.compile(r"[0-9a-fA-F]{4}")
27
+ _lenient_decode = json.JSONDecoder(strict=False).decode
28
+ _strict_decode = json.JSONDecoder(strict=True).decode
29
+
30
+
31
+ def _escape_control(char: str) -> str:
32
+ match char:
33
+ case "\b":
34
+ return "\\b"
35
+ case "\f":
36
+ return "\\f"
37
+ case "\n":
38
+ return "\\n"
39
+ case "\r":
40
+ return "\\r"
41
+ case "\t":
42
+ return "\\t"
43
+ case _:
44
+ return f"\\u{ord(char):04x}"
45
+
46
+
47
+ def repair_json(text: str) -> str:
48
+ """Repair malformed JSON string literals.
49
+
50
+ Escapes raw control characters inside strings and doubles backslashes before
51
+ invalid escape characters.
52
+ """
53
+ out: list[str] = []
54
+ in_string = False
55
+ index = 0
56
+ length = len(text)
57
+ while index < length:
58
+ char = text[index]
59
+ if not in_string:
60
+ out.append(char)
61
+ if char == '"':
62
+ in_string = True
63
+ index += 1
64
+ continue
65
+ if char == '"':
66
+ out.append(char)
67
+ in_string = False
68
+ index += 1
69
+ continue
70
+ if char == "\\":
71
+ if index + 1 >= length:
72
+ out.append("\\\\")
73
+ index += 1
74
+ continue
75
+ next_char = text[index + 1]
76
+ if next_char == "u" and _HEX4.fullmatch(text, index + 2, index + 6):
77
+ out.append(text[index : index + 6])
78
+ index += 6
79
+ continue
80
+ if next_char in _VALID_ESCAPES:
81
+ out.append("\\" + next_char)
82
+ index += 2
83
+ continue
84
+ out.append("\\\\")
85
+ index += 1
86
+ continue
87
+ out.append(_escape_control(char) if ord(char) < 0x20 else char)
88
+ index += 1
89
+ return "".join(out)
90
+
91
+
92
+ def parse_json_with_repair(text: str) -> Any:
93
+ """``json.loads`` that retries once with :func:`repair_json` applied."""
94
+ try:
95
+ return _strict_decode(text)
96
+ except ValueError:
97
+ repaired = repair_json(text)
98
+ if repaired != text:
99
+ return _strict_decode(repaired)
100
+ raise
101
+
102
+
103
+ def parse_tool_call_arguments(text: str | None) -> tuple[dict[str, Any], str | None]:
104
+ """Parse the complete arguments text of a finished tool call.
105
+
106
+ Returns ``(arguments, arguments_error)``. Unlike :func:`parse_streaming_json`,
107
+ this never keeps a partially parsed prefix: text that is not a valid JSON object
108
+ yields ``({}, error)`` where the error includes the text the model sent, so tool
109
+ validation can report it back to the model.
110
+ """
111
+ if not text or not text.strip():
112
+ return {}, None
113
+ try:
114
+ parsed = parse_json_with_repair(text)
115
+ if not isinstance(parsed, dict):
116
+ raise ValueError("Tool arguments must be a JSON object")
117
+ return parsed, None
118
+ except ValueError as error:
119
+ return {}, f"{error}\n\nReceived arguments:\n{text}"
120
+
121
+
122
+ def parse_streaming_json(partial_json: str | None) -> Any:
123
+ """Best-effort parse of possibly incomplete JSON. Always returns a value (``{}`` on failure)."""
124
+ if not partial_json or not partial_json.strip():
125
+ return {}
126
+ try:
127
+ return parse_json_with_repair(partial_json)
128
+ except ValueError:
129
+ pass
130
+ parser = StreamingJsonParser()
131
+ parser.feed(partial_json)
132
+ value = parser.value(default=None)
133
+ return {} if value is None else value
134
+
135
+
136
+ def complete_partial_json(partial_json: str) -> str | None:
137
+ """Close an incomplete JSON prefix into parseable JSON text, or ``None`` if nothing is usable."""
138
+ scanner = _Scanner()
139
+ scanner.feed(partial_json)
140
+ return scanner.complete(partial_json)
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Incremental scanner
145
+ # ---------------------------------------------------------------------------
146
+
147
+ # Frame states
148
+ _OBJ_KEY = 0 # after "{" or ",": expecting a key or "}"
149
+ _OBJ_IN_KEY = 1 # inside the key string
150
+ _OBJ_COLON = 2 # key done, expecting ":"
151
+ _OBJ_VALUE = 3 # after ":", expecting a value
152
+ _AFTER_VALUE = 4 # value done, expecting "," or the closer
153
+ _ARR_VALUE = 5 # after "[" or ",": expecting a value or "]"
154
+
155
+ _LITERALS = ("true", "false", "null")
156
+ _NUMBER_RE = re.compile(r"-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?")
157
+ _STRING_SPECIAL = re.compile(r'["\\]')
158
+ _SCALAR_END = re.compile(r"[\s,\]}]")
159
+
160
+
161
+ class _Frame:
162
+ __slots__ = ("closer", "safe", "state")
163
+
164
+ def __init__(self, closer: str, state: int, safe: int) -> None:
165
+ self.closer = closer
166
+ self.state = state
167
+ #: Prefix length that is structurally valid for this container once closed.
168
+ self.safe = safe
169
+
170
+
171
+ class _Scanner:
172
+ """Resumable JSON lexer that tracks where a prefix can be safely cut and closed."""
173
+
174
+ __slots__ = ("in_string", "invalid", "pos", "scalar_start", "stack", "string_is_key", "top_done")
175
+
176
+ def __init__(self) -> None:
177
+ self.pos = 0
178
+ self.stack: list[_Frame] = []
179
+ self.in_string = False
180
+ self.string_is_key = False
181
+ self.scalar_start = -1 # start of a number/literal token in progress
182
+ self.top_done = False
183
+ self.invalid = False
184
+
185
+ def feed(self, text: str) -> None:
186
+ if self.invalid:
187
+ return
188
+ pos = self.pos
189
+ length = len(text)
190
+ stack = self.stack
191
+ while pos < length:
192
+ if self.in_string:
193
+ match = _STRING_SPECIAL.search(text, pos)
194
+ if match is None:
195
+ pos = length
196
+ break
197
+ index = match.start()
198
+ if text[index] == "\\":
199
+ if index + 1 >= length:
200
+ pos = index # wait for the escaped character
201
+ break
202
+ pos = index + 2
203
+ continue
204
+ pos = index + 1
205
+ self.in_string = False
206
+ self._value_done(pos, is_key=self.string_is_key)
207
+ continue
208
+
209
+ if self.scalar_start >= 0:
210
+ match = _SCALAR_END.search(text, pos)
211
+ if match is None:
212
+ pos = length
213
+ break
214
+ pos = match.start()
215
+ self.scalar_start = -1
216
+ self._value_done(pos, is_key=False)
217
+ continue
218
+
219
+ char = text[pos]
220
+ if char in " \t\r\n":
221
+ pos += 1
222
+ continue
223
+ frame = stack[-1] if stack else None
224
+ if char == '"':
225
+ if frame is not None and frame.state == _OBJ_KEY:
226
+ frame.state = _OBJ_IN_KEY
227
+ self.string_is_key = True
228
+ else:
229
+ if not self._expect_value(frame):
230
+ break
231
+ self.string_is_key = False
232
+ self.in_string = True
233
+ pos += 1
234
+ continue
235
+ if char == "{" or char == "[":
236
+ if not self._expect_value(frame):
237
+ break
238
+ pos += 1
239
+ stack.append(_Frame("}" if char == "{" else "]", _OBJ_KEY if char == "{" else _ARR_VALUE, pos))
240
+ continue
241
+ if char == "}" or char == "]":
242
+ if frame is None or frame.closer != char or frame.state not in (_OBJ_KEY, _AFTER_VALUE, _ARR_VALUE):
243
+ self.invalid = True
244
+ break
245
+ stack.pop()
246
+ pos += 1
247
+ self._value_done(pos, is_key=False)
248
+ continue
249
+ if char == ",":
250
+ if frame is None or frame.state != _AFTER_VALUE:
251
+ self.invalid = True
252
+ break
253
+ frame.state = _OBJ_KEY if frame.closer == "}" else _ARR_VALUE
254
+ pos += 1
255
+ continue
256
+ if char == ":":
257
+ if frame is None or frame.state != _OBJ_COLON:
258
+ self.invalid = True
259
+ break
260
+ frame.state = _OBJ_VALUE
261
+ pos += 1
262
+ continue
263
+ # number or literal
264
+ if not self._expect_value(frame):
265
+ break
266
+ self.scalar_start = pos
267
+ pos += 1
268
+ self.pos = pos
269
+
270
+ def _expect_value(self, frame: _Frame | None) -> bool:
271
+ if frame is None:
272
+ if self.top_done:
273
+ self.invalid = True
274
+ return False
275
+ return True
276
+ if frame.state in (_OBJ_VALUE, _ARR_VALUE):
277
+ return True
278
+ self.invalid = True
279
+ return False
280
+
281
+ def _value_done(self, end: int, *, is_key: bool) -> None:
282
+ if not self.stack:
283
+ self.top_done = True
284
+ return
285
+ frame = self.stack[-1]
286
+ if is_key:
287
+ frame.state = _OBJ_COLON
288
+ else:
289
+ frame.state = _AFTER_VALUE
290
+ frame.safe = end
291
+
292
+ def complete(self, text: str) -> str | None:
293
+ """Return ``text`` cut and closed into parseable JSON (``None`` if nothing usable)."""
294
+ if self.invalid:
295
+ return None
296
+ stack = self.stack
297
+ if not stack:
298
+ if self.top_done:
299
+ return text
300
+ if self.in_string:
301
+ return _close_string(text)
302
+ if self.scalar_start >= 0:
303
+ return _complete_scalar(text[self.scalar_start :])
304
+ return None
305
+
306
+ frame = stack[-1]
307
+ suffix = "".join(f.closer for f in reversed(stack))
308
+ if self.in_string:
309
+ if self.string_is_key:
310
+ return text[: frame.safe] + suffix
311
+ return _close_string(text) + suffix
312
+ if self.scalar_start >= 0:
313
+ scalar = _complete_scalar(text[self.scalar_start :])
314
+ if scalar is None:
315
+ return text[: frame.safe] + suffix
316
+ return text[: self.scalar_start] + scalar + suffix
317
+ if frame.state == _AFTER_VALUE:
318
+ return text + suffix
319
+ return text[: frame.safe] + suffix
320
+
321
+
322
+ def _close_string(text: str) -> str:
323
+ # Drop a dangling escape so the closing quote is not escaped.
324
+ end = len(text)
325
+ backslashes = 0
326
+ while end - backslashes - 1 >= 0 and text[end - backslashes - 1] == "\\":
327
+ backslashes += 1
328
+ if backslashes % 2 == 1:
329
+ text = text[:-1]
330
+ else:
331
+ unicode_start = text.rfind("\\u", max(0, len(text) - 5))
332
+ if unicode_start != -1 and len(text) - unicode_start < 6:
333
+ preceding = 0
334
+ while unicode_start - preceding - 1 >= 0 and text[unicode_start - preceding - 1] == "\\":
335
+ preceding += 1
336
+ if preceding % 2 == 0:
337
+ text = text[:unicode_start]
338
+ return text + '"'
339
+
340
+
341
+ def _complete_scalar(token: str) -> str | None:
342
+ if not token:
343
+ return None
344
+ first = token[0]
345
+ if first in "tfn":
346
+ for literal in _LITERALS:
347
+ if literal.startswith(token):
348
+ return literal
349
+ return None
350
+ match = _NUMBER_RE.match(token)
351
+ if match is None:
352
+ return None
353
+ return match.group(0)
354
+
355
+
356
+ class StreamingJsonParser:
357
+ """Incrementally parse a growing JSON document.
358
+
359
+ ::
360
+
361
+ parser = StreamingJsonParser()
362
+ for delta in deltas:
363
+ parser.feed(delta)
364
+ partial_arguments = parser.value()
365
+
366
+ :meth:`value` returns the best parse of the text so far. When the current prefix
367
+ cannot be parsed (for example invalid escapes the repair pass cannot fix), the
368
+ last successful value is returned instead of an empty object, so partial
369
+ arguments never regress during streaming.
370
+ """
371
+
372
+ __slots__ = ("_dirty", "_last", "_scanner", "_text")
373
+
374
+ def __init__(self) -> None:
375
+ self._text = ""
376
+ self._scanner = _Scanner()
377
+ self._last: Any = None
378
+ self._dirty = False
379
+
380
+ @property
381
+ def text(self) -> str:
382
+ return self._text
383
+
384
+ def feed(self, delta: str) -> None:
385
+ if not delta:
386
+ return
387
+ self._text += delta
388
+ self._scanner.feed(self._text)
389
+ self._dirty = True
390
+
391
+ def value(self, default: Any = ...) -> Any:
392
+ if not self._dirty:
393
+ if self._last is not None:
394
+ return self._last
395
+ return {} if default is ... else default
396
+ self._dirty = False
397
+ text = self._text
398
+ scanner = self._scanner
399
+ # While the scanner knows the document is still open, a full parse can only fail.
400
+ if not scanner.invalid and (scanner.stack or scanner.in_string or not scanner.top_done):
401
+ parsed = _FAIL
402
+ else:
403
+ parsed = _try_parse(text)
404
+ if parsed is _FAIL:
405
+ completed = self._scanner.complete(text)
406
+ if completed is not None:
407
+ parsed = _try_parse(completed)
408
+ if parsed is _FAIL:
409
+ parsed = _try_parse(repair_json(completed))
410
+ if parsed is not _FAIL:
411
+ self._last = parsed
412
+ if self._last is None:
413
+ return {} if default is ... else default
414
+ return self._last
415
+
416
+
417
+ _FAIL: Any = object()
418
+
419
+
420
+ def _try_parse(text: str) -> Any:
421
+ try:
422
+ return _lenient_decode(text)
423
+ except ValueError:
424
+ return _FAIL
pi_ai/utils/misc.py ADDED
@@ -0,0 +1,193 @@
1
+ """Small shared helpers: env lookup, headers, hashing, ids, unicode, user agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import re
8
+ import secrets
9
+ import sys
10
+ import threading
11
+ import time
12
+ from collections.abc import Iterable, Mapping
13
+ from typing import Any
14
+
15
+ __all__ = [
16
+ "get_provider_env_value",
17
+ "headers_to_record",
18
+ "provider_headers_to_record",
19
+ "merge_headers",
20
+ "has_header",
21
+ "short_hash",
22
+ "uuidv7",
23
+ "sanitize_surrogates",
24
+ "get_pi_user_agent",
25
+ ]
26
+
27
+
28
+ def get_provider_env_value(name: str, env: Mapping[str, str] | None = None) -> str | None:
29
+ """Resolve a provider env value from scoped overrides, then ``os.environ``.
30
+
31
+ Empty strings count as unset, like JavaScript's ``||``.
32
+ """
33
+ if env:
34
+ value = env.get(name)
35
+ if value:
36
+ return value
37
+ value = os.environ.get(name)
38
+ return value or None
39
+
40
+
41
+ def headers_to_record(headers: Any) -> dict[str, str]:
42
+ """Convert response headers (``httpx.Headers`` or a mapping) to a lowercase-key dict."""
43
+ items = headers.multi_items() if hasattr(headers, "multi_items") else headers.items()
44
+ result: dict[str, str] = {}
45
+ for key, value in items:
46
+ lower = key.lower()
47
+ result[lower] = f"{result[lower]}, {value}" if lower in result else value
48
+ return result
49
+
50
+
51
+ def provider_headers_to_record(*sources: Mapping[str, str | None] | None) -> dict[str, str] | None:
52
+ """Merge header sources case-insensitively; later sources win and ``None`` deletes."""
53
+ merged: dict[str, tuple[str, str]] = {}
54
+ for source in sources:
55
+ if not source:
56
+ continue
57
+ for name, value in source.items():
58
+ lower = name.lower()
59
+ merged.pop(lower, None)
60
+ if value is not None:
61
+ merged[lower] = (name, value)
62
+ return {name: value for name, value in merged.values()} if merged else None
63
+
64
+
65
+ def merge_headers(
66
+ base: Mapping[str, str | None] | None, override: Mapping[str, str | None] | None
67
+ ) -> dict[str, str | None] | None:
68
+ """Case-insensitive header merge that keeps ``None`` suppression markers."""
69
+ if not base and not override:
70
+ return None
71
+ merged: dict[str, str | None] = dict(base or {})
72
+ for name, value in (override or {}).items():
73
+ lower = name.lower()
74
+ for existing in [key for key in merged if key.lower() == lower]:
75
+ del merged[existing]
76
+ merged[name] = value
77
+ return merged
78
+
79
+
80
+ def has_header(headers: Mapping[str, Any] | None, name: str) -> bool:
81
+ if not headers:
82
+ return False
83
+ lower = name.lower()
84
+ return any(key.lower() == lower and value is not None for key, value in headers.items())
85
+
86
+
87
+ _MASK = 0xFFFFFFFF
88
+ _BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz"
89
+
90
+
91
+ def _imul(a: int, b: int) -> int:
92
+ return ((a & _MASK) * (b & _MASK)) & _MASK
93
+
94
+
95
+ def _to_base36(value: int) -> str:
96
+ if value == 0:
97
+ return "0"
98
+ digits: list[str] = []
99
+ while value:
100
+ value, remainder = divmod(value, 36)
101
+ digits.append(_BASE36[remainder])
102
+ return "".join(reversed(digits))
103
+
104
+
105
+ def _utf16_units(text: str) -> Iterable[int]:
106
+ for char in text:
107
+ code = ord(char)
108
+ if code > 0xFFFF:
109
+ code -= 0x10000
110
+ yield 0xD800 + (code >> 10)
111
+ yield 0xDC00 + (code & 0x3FF)
112
+ else:
113
+ yield code
114
+
115
+
116
+ def short_hash(text: str) -> str:
117
+ """Fast deterministic hash used to shorten long ids (bit-identical to pi's ``shortHash``)."""
118
+ h1 = 0xDEADBEEF
119
+ h2 = 0x41C6CE57
120
+ for unit in _utf16_units(text):
121
+ h1 = _imul(h1 ^ unit, 2654435761)
122
+ h2 = _imul(h2 ^ unit, 1597334677)
123
+ h1 = _imul(h1 ^ (h1 >> 16), 2246822507) ^ _imul(h2 ^ (h2 >> 13), 3266489909)
124
+ h2 = _imul(h2 ^ (h2 >> 16), 2246822507) ^ _imul(h1 ^ (h1 >> 13), 3266489909)
125
+ return _to_base36(h2 & _MASK) + _to_base36(h1 & _MASK)
126
+
127
+
128
+ _MAX_UUID_V7_TIMESTAMP = 0xFFFFFFFFFFFF
129
+ _MAX_SEQUENCE = (1 << 41) - 1
130
+ _uuid_lock = threading.Lock()
131
+ _last_ordinary_timestamp = -1
132
+ _sequence: int | None = None
133
+
134
+
135
+ def uuidv7(timestamp_ms: int | None = None) -> str:
136
+ """Generate a time-ordered UUIDv7. A supplied timestamp is preserved for follower ids."""
137
+ global _last_ordinary_timestamp, _sequence
138
+ requested = timestamp_ms if timestamp_ms is not None else time.time_ns() // 1_000_000
139
+ if not isinstance(requested, int) or requested < 0 or requested > _MAX_UUID_V7_TIMESTAMP:
140
+ raise ValueError(f"UUIDv7 timestamp must be an integer between 0 and {_MAX_UUID_V7_TIMESTAMP}")
141
+ random_bytes = bytearray(secrets.token_bytes(16))
142
+ with _uuid_lock:
143
+ if timestamp_ms is None:
144
+ effective = max(requested, _last_ordinary_timestamp)
145
+ _last_ordinary_timestamp = effective
146
+ else:
147
+ effective = timestamp_ms
148
+ if _sequence is None:
149
+ _sequence = int.from_bytes(random_bytes[1:6], "big")
150
+ else:
151
+ if _sequence == _MAX_SEQUENCE:
152
+ raise OverflowError("UUIDv7 generator sequence exhausted")
153
+ _sequence += 1
154
+ sequence = _sequence
155
+ data = bytearray(16)
156
+ data[0:6] = effective.to_bytes(6, "big")
157
+ data[6] = 0x70 | ((sequence >> 37) & 0x0F)
158
+ data[7] = (sequence >> 29) & 0xFF
159
+ data[8] = 0x80 | ((sequence >> 23) & 0x3F)
160
+ data[9] = (sequence >> 15) & 0xFF
161
+ data[10] = (sequence >> 7) & 0xFF
162
+ data[11] = ((sequence & 0x7F) << 1) | (random_bytes[11] & 0x01)
163
+ data[12:16] = random_bytes[12:16]
164
+ hex_text = data.hex()
165
+ return f"{hex_text[0:8]}-{hex_text[8:12]}-{hex_text[12:16]}-{hex_text[16:20]}-{hex_text[20:32]}"
166
+
167
+
168
+ _SURROGATE_RE = re.compile("[\ud800-\udfff]")
169
+
170
+
171
+ def sanitize_surrogates(text: str) -> str:
172
+ """Remove unpaired UTF-16 surrogates (they break JSON/UTF-8 encoding).
173
+
174
+ Properly paired surrogates are joined into their code point, so emoji survive.
175
+ """
176
+ if _SURROGATE_RE.search(text) is None:
177
+ return text
178
+ joined = text.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "ignore")
179
+ return _SURROGATE_RE.sub("", joined)
180
+
181
+
182
+ _user_agent: str | None = None
183
+
184
+
185
+ def get_pi_user_agent() -> str:
186
+ global _user_agent
187
+ if _user_agent is None:
188
+ machine = platform.machine().lower()
189
+ arch = {"x86_64": "x64", "amd64": "x64", "aarch64": "arm64", "i386": "ia32", "i686": "ia32"}.get(
190
+ machine, machine
191
+ )
192
+ _user_agent = f"pi ({sys.platform} {platform.release()}; {arch})"
193
+ return _user_agent
@@ -0,0 +1,73 @@
1
+ """Model-type narrowing and operation result helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..types import AssistantImages, ClassifierContext, ClassifierModel, ClassifierResult, ImageModel, now_ms
8
+ from .diagnostics import format_thrown_value
9
+ from .models_error import ModelsError
10
+
11
+ __all__ = [
12
+ "get_model_type",
13
+ "is_model_type",
14
+ "assert_chat_model",
15
+ "assert_image_model",
16
+ "assert_classifier_model",
17
+ "assert_classifier_input_supported",
18
+ "image_error_result",
19
+ "classifier_error_result",
20
+ ]
21
+
22
+
23
+ def get_model_type(model: Any) -> str:
24
+ """The type of a model. Models without ``type`` are chat models."""
25
+ return getattr(model, "type", None) or "chat"
26
+
27
+
28
+ def is_model_type(model: Any, type: str) -> bool:
29
+ return get_model_type(model) == type
30
+
31
+
32
+ def assert_chat_model(model: Any) -> None:
33
+ if not is_model_type(model, "chat"):
34
+ raise ModelsError("provider", f"Model {model.provider}/{model.id} is not a chat model")
35
+
36
+
37
+ def assert_image_model(model: Any) -> None:
38
+ if not is_model_type(model, "image"):
39
+ raise ModelsError("provider", f"Model {model.provider}/{model.id} is not an image model")
40
+
41
+
42
+ def assert_classifier_model(model: Any) -> None:
43
+ if not is_model_type(model, "classifier"):
44
+ raise ModelsError("provider", f"Model {model.provider}/{model.id} is not a classifier model")
45
+
46
+
47
+ def assert_classifier_input_supported(model: ClassifierModel, context: ClassifierContext) -> None:
48
+ if context.images and "image" not in model.input:
49
+ raise ModelsError("provider", f"Model {model.provider}/{model.id} does not accept image input")
50
+
51
+
52
+ def image_error_result(model: ImageModel, error: Any, aborted: bool = False) -> AssistantImages:
53
+ return AssistantImages(
54
+ api=model.api,
55
+ provider=model.provider,
56
+ model=model.id,
57
+ output=[],
58
+ stop_reason="aborted" if aborted else "error",
59
+ error_message=format_thrown_value(error),
60
+ timestamp=now_ms(),
61
+ )
62
+
63
+
64
+ def classifier_error_result(model: ClassifierModel, error: Any, aborted: bool = False) -> ClassifierResult:
65
+ return ClassifierResult(
66
+ api=model.api,
67
+ provider=model.provider,
68
+ model=model.id,
69
+ answers={},
70
+ stop_reason="aborted" if aborted else "error",
71
+ error_message=format_thrown_value(error),
72
+ timestamp=now_ms(),
73
+ )