pi-ai-python 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
|
@@ -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
|
+
)
|