pi-ai-python 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pi_agent/__init__.py +127 -0
- pi_agent/agent.py +617 -0
- pi_agent/agent_loop.py +820 -0
- pi_agent/proxy.py +269 -0
- pi_agent/py.typed +0 -0
- pi_agent/stream_fn.py +27 -0
- pi_agent/tools.py +327 -0
- pi_agent/types.py +394 -0
- pi_ai/__init__.py +263 -0
- pi_ai/abort.py +315 -0
- pi_ai/api/__init__.py +1 -0
- pi_ai/api/anthropic_messages.py +1782 -0
- pi_ai/api/azure_openai_config.py +138 -0
- pi_ai/api/azure_openai_responses.py +245 -0
- pi_ai/api/bedrock_converse_stream.py +1598 -0
- pi_ai/api/classifier_shared.py +211 -0
- pi_ai/api/cloudflare.py +36 -0
- pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
- pi_ai/api/constrained_sampling.py +279 -0
- pi_ai/api/github_copilot_headers.py +30 -0
- pi_ai/api/google_generative_ai.py +138 -0
- pi_ai/api/google_shared.py +927 -0
- pi_ai/api/google_vertex.py +234 -0
- pi_ai/api/lazy.py +156 -0
- pi_ai/api/llama_cpp_classify.py +449 -0
- pi_ai/api/mistral_conversations.py +756 -0
- pi_ai/api/openai_codex_responses.py +1545 -0
- pi_ai/api/openai_completions.py +1594 -0
- pi_ai/api/openai_decisions.py +210 -0
- pi_ai/api/openai_prompt_cache.py +19 -0
- pi_ai/api/openai_responses.py +418 -0
- pi_ai/api/openai_responses_shared.py +840 -0
- pi_ai/api/openai_sdk.py +204 -0
- pi_ai/api/openrouter_images.py +153 -0
- pi_ai/api/pi_messages.py +449 -0
- pi_ai/api/registry.py +103 -0
- pi_ai/api/simple_options.py +119 -0
- pi_ai/api/system_one_shared.py +148 -0
- pi_ai/api/transform_messages.py +211 -0
- pi_ai/api/typesafe_system_one.py +43 -0
- pi_ai/auth/__init__.py +1 -0
- pi_ai/auth/context.py +47 -0
- pi_ai/auth/credential_store.py +208 -0
- pi_ai/auth/helpers.py +123 -0
- pi_ai/auth/oauth/__init__.py +5 -0
- pi_ai/auth/oauth/_common.py +454 -0
- pi_ai/auth/oauth/anthropic.py +283 -0
- pi_ai/auth/oauth/callback_server.py +339 -0
- pi_ai/auth/oauth/device_code.py +127 -0
- pi_ai/auth/oauth/github_copilot.py +508 -0
- pi_ai/auth/oauth/kimi_coding.py +283 -0
- pi_ai/auth/oauth/load.py +64 -0
- pi_ai/auth/oauth/meta.py +238 -0
- pi_ai/auth/oauth/openai_chatgpt.py +321 -0
- pi_ai/auth/oauth/openai_codex.py +391 -0
- pi_ai/auth/oauth/openrouter.py +173 -0
- pi_ai/auth/oauth/pkce.py +23 -0
- pi_ai/auth/oauth/radius.py +311 -0
- pi_ai/auth/oauth/xai.py +220 -0
- pi_ai/auth/resolve.py +190 -0
- pi_ai/auth/types.py +292 -0
- pi_ai/cli.py +265 -0
- pi_ai/compat.py +651 -0
- pi_ai/env_api_keys.py +137 -0
- pi_ai/event_stream.py +329 -0
- pi_ai/models.py +1191 -0
- pi_ai/models_store.py +121 -0
- pi_ai/oauth.py +90 -0
- pi_ai/providers/__init__.py +1 -0
- pi_ai/providers/all.py +154 -0
- pi_ai/providers/amazon_bedrock.py +116 -0
- pi_ai/providers/ant_ling.py +23 -0
- pi_ai/providers/anthropic.py +105 -0
- pi_ai/providers/azure.py +103 -0
- pi_ai/providers/baseten.py +23 -0
- pi_ai/providers/catalog.py +167 -0
- pi_ai/providers/cerebras.py +23 -0
- pi_ai/providers/cloudflare_ai_gateway.py +30 -0
- pi_ai/providers/cloudflare_auth.py +112 -0
- pi_ai/providers/cloudflare_stream.py +84 -0
- pi_ai/providers/cloudflare_workers_ai.py +26 -0
- pi_ai/providers/data/amazon-bedrock.json +1 -0
- pi_ai/providers/data/ant-ling.json +1 -0
- pi_ai/providers/data/anthropic.json +1 -0
- pi_ai/providers/data/azure.json +1 -0
- pi_ai/providers/data/baseten.json +1 -0
- pi_ai/providers/data/cerebras.json +1 -0
- pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
- pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
- pi_ai/providers/data/deepseek.json +1 -0
- pi_ai/providers/data/fireworks.json +1 -0
- pi_ai/providers/data/github-copilot.json +1 -0
- pi_ai/providers/data/google-vertex.json +1 -0
- pi_ai/providers/data/google.json +1 -0
- pi_ai/providers/data/groq.json +1 -0
- pi_ai/providers/data/huggingface.json +1 -0
- pi_ai/providers/data/kimi-coding.json +1 -0
- pi_ai/providers/data/manifest.json +1 -0
- pi_ai/providers/data/meta.json +1 -0
- pi_ai/providers/data/minimax-cn.json +1 -0
- pi_ai/providers/data/minimax.json +1 -0
- pi_ai/providers/data/mistral.json +1 -0
- pi_ai/providers/data/moonshotai-cn.json +1 -0
- pi_ai/providers/data/moonshotai.json +1 -0
- pi_ai/providers/data/nvidia.json +1 -0
- pi_ai/providers/data/openai-codex.json +1 -0
- pi_ai/providers/data/openai.json +1 -0
- pi_ai/providers/data/opencode-go.json +1 -0
- pi_ai/providers/data/opencode.json +1 -0
- pi_ai/providers/data/openrouter.json +1 -0
- pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
- pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
- pi_ai/providers/data/qwen-token-plan.json +1 -0
- pi_ai/providers/data/radius.json +1 -0
- pi_ai/providers/data/together.json +1 -0
- pi_ai/providers/data/typesafe.json +1 -0
- pi_ai/providers/data/vercel-ai-gateway.json +1 -0
- pi_ai/providers/data/xai.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
- pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
- pi_ai/providers/data/xiaomi.json +1 -0
- pi_ai/providers/data/zai-coding-cn.json +1 -0
- pi_ai/providers/data/zai.json +1 -0
- pi_ai/providers/deepseek.py +23 -0
- pi_ai/providers/faux.py +680 -0
- pi_ai/providers/fireworks.py +26 -0
- pi_ai/providers/github_copilot.py +46 -0
- pi_ai/providers/google.py +23 -0
- pi_ai/providers/google_vertex.py +131 -0
- pi_ai/providers/groq.py +23 -0
- pi_ai/providers/huggingface.py +23 -0
- pi_ai/providers/kimi_coding.py +32 -0
- pi_ai/providers/meta.py +32 -0
- pi_ai/providers/minimax.py +23 -0
- pi_ai/providers/minimax_cn.py +23 -0
- pi_ai/providers/mistral.py +23 -0
- pi_ai/providers/moonshotai.py +23 -0
- pi_ai/providers/moonshotai_cn.py +23 -0
- pi_ai/providers/nvidia.py +23 -0
- pi_ai/providers/openai.py +44 -0
- pi_ai/providers/openai_codex.py +26 -0
- pi_ai/providers/opencode.py +36 -0
- pi_ai/providers/opencode_go.py +33 -0
- pi_ai/providers/opencode_headers.py +60 -0
- pi_ai/providers/openrouter.py +145 -0
- pi_ai/providers/openrouter_api.py +122 -0
- pi_ai/providers/qwen_token_plan.py +23 -0
- pi_ai/providers/qwen_token_plan_cn.py +23 -0
- pi_ai/providers/qwen_token_plan_individual.py +23 -0
- pi_ai/providers/radius.py +146 -0
- pi_ai/providers/radius_config.py +136 -0
- pi_ai/providers/together.py +23 -0
- pi_ai/providers/typesafe.py +22 -0
- pi_ai/providers/vercel_ai_gateway.py +25 -0
- pi_ai/providers/xai.py +32 -0
- pi_ai/providers/xiaomi.py +23 -0
- pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
- pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
- pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
- pi_ai/providers/zai.py +23 -0
- pi_ai/providers/zai_coding_cn.py +23 -0
- pi_ai/py.typed +0 -0
- pi_ai/serialization.py +477 -0
- pi_ai/session_resources.py +33 -0
- pi_ai/types.py +1003 -0
- pi_ai/utils/__init__.py +1 -0
- pi_ai/utils/callbacks.py +15 -0
- pi_ai/utils/diagnostics.py +70 -0
- pi_ai/utils/error_body.py +89 -0
- pi_ai/utils/estimate.py +125 -0
- pi_ai/utils/fastjson.py +63 -0
- pi_ai/utils/http.py +409 -0
- pi_ai/utils/json_parse.py +424 -0
- pi_ai/utils/misc.py +193 -0
- pi_ai/utils/model_operations.py +73 -0
- pi_ai/utils/models_error.py +35 -0
- pi_ai/utils/oauth_page.py +121 -0
- pi_ai/utils/overflow.py +87 -0
- pi_ai/utils/retry.py +193 -0
- pi_ai/utils/schema.py +283 -0
- pi_ai/utils/text.py +65 -0
- pi_ai/utils/transcript.py +271 -0
- pi_ai/utils/validation.py +813 -0
- pi_ai_python-1.3.0.dist-info/METADATA +167 -0
- pi_ai_python-1.3.0.dist-info/RECORD +189 -0
- pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
- pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
- pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
pi_ai/utils/schema.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""JSON Schema builders for tool parameters.
|
|
2
|
+
|
|
3
|
+
:class:`Type` is a small TypeBox-style builder (the TypeScript package re-exports
|
|
4
|
+
TypeBox's ``Type``). Every builder returns a plain ``dict`` subclass, so schemas
|
|
5
|
+
serialize as ordinary JSON Schema and can be mixed with hand-written dicts::
|
|
6
|
+
|
|
7
|
+
from pi_ai import Type, StringEnum
|
|
8
|
+
|
|
9
|
+
params = Type.Object({
|
|
10
|
+
"location": Type.String(description="City name"),
|
|
11
|
+
"units": StringEnum(["celsius", "fahrenheit"], default="celsius"),
|
|
12
|
+
"days": Type.Optional(Type.Integer(minimum=1, maximum=14)),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
Tool parameters may also be Pydantic model classes; :func:`parameters_schema`
|
|
16
|
+
converts them to JSON Schema once and caches the result.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
__all__ = ["TSchema", "Type", "StringEnum", "parameters_schema", "is_pydantic_model", "clone_schema"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TSchema(dict[str, Any]):
|
|
29
|
+
"""A JSON Schema node produced by :class:`Type`.
|
|
30
|
+
|
|
31
|
+
``optional`` marks a property as not required when used inside ``Type.Object``.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
__slots__ = ("optional",)
|
|
35
|
+
|
|
36
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
37
|
+
super().__init__(*args, **kwargs)
|
|
38
|
+
self.optional = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _node(base: Mapping[str, Any], options: Mapping[str, Any]) -> TSchema:
|
|
42
|
+
schema = TSchema(base)
|
|
43
|
+
for key, value in options.items():
|
|
44
|
+
if value is not None:
|
|
45
|
+
schema[_option_key(key)] = value
|
|
46
|
+
return schema
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_OPTION_KEYS = {
|
|
50
|
+
"min_length": "minLength",
|
|
51
|
+
"max_length": "maxLength",
|
|
52
|
+
"min_items": "minItems",
|
|
53
|
+
"max_items": "maxItems",
|
|
54
|
+
"unique_items": "uniqueItems",
|
|
55
|
+
"exclusive_minimum": "exclusiveMinimum",
|
|
56
|
+
"exclusive_maximum": "exclusiveMaximum",
|
|
57
|
+
"multiple_of": "multipleOf",
|
|
58
|
+
"additional_properties": "additionalProperties",
|
|
59
|
+
"min_properties": "minProperties",
|
|
60
|
+
"max_properties": "maxProperties",
|
|
61
|
+
"pattern_properties": "patternProperties",
|
|
62
|
+
"content_encoding": "contentEncoding",
|
|
63
|
+
"content_media_type": "contentMediaType",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _option_key(key: str) -> str:
|
|
68
|
+
return _OPTION_KEYS.get(key, key)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Type:
|
|
72
|
+
"""TypeBox-style JSON Schema builders. Keyword options map to JSON Schema keywords
|
|
73
|
+
(``min_length`` → ``minLength``; camelCase keywords are accepted as-is)."""
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def Object(properties: Mapping[str, Any] | None = None, **options: Any) -> TSchema:
|
|
77
|
+
props: dict[str, Any] = {}
|
|
78
|
+
required: list[str] = []
|
|
79
|
+
for name, value in (properties or {}).items():
|
|
80
|
+
props[name] = _strip_optional(value)
|
|
81
|
+
if not getattr(value, "optional", False):
|
|
82
|
+
required.append(name)
|
|
83
|
+
base: dict[str, Any] = {"type": "object", "properties": props}
|
|
84
|
+
if required:
|
|
85
|
+
base["required"] = required
|
|
86
|
+
return _node(base, options)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def String(**options: Any) -> TSchema:
|
|
90
|
+
return _node({"type": "string"}, options)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def Number(**options: Any) -> TSchema:
|
|
94
|
+
return _node({"type": "number"}, options)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def Integer(**options: Any) -> TSchema:
|
|
98
|
+
return _node({"type": "integer"}, options)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def Boolean(**options: Any) -> TSchema:
|
|
102
|
+
return _node({"type": "boolean"}, options)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def Null(**options: Any) -> TSchema:
|
|
106
|
+
return _node({"type": "null"}, options)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def Any(**options: Any) -> TSchema:
|
|
110
|
+
return _node({}, options)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def Unknown(**options: Any) -> TSchema:
|
|
114
|
+
return _node({}, options)
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def Array(items: Mapping[str, Any], **options: Any) -> TSchema:
|
|
118
|
+
return _node({"type": "array", "items": _strip_optional(items)}, options)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def Tuple(items: Sequence[Mapping[str, Any]], **options: Any) -> TSchema:
|
|
122
|
+
values = [_strip_optional(item) for item in items]
|
|
123
|
+
return _node(
|
|
124
|
+
{"type": "array", "prefixItems": values, "items": False, "minItems": len(values), "maxItems": len(values)},
|
|
125
|
+
options,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def Literal(value: Any, **options: Any) -> TSchema:
|
|
130
|
+
base: dict[str, Any] = {"const": value}
|
|
131
|
+
json_type = _json_type_of(value)
|
|
132
|
+
if json_type:
|
|
133
|
+
base["type"] = json_type
|
|
134
|
+
return _node(base, options)
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def Union(schemas: Iterable[Mapping[str, Any]], **options: Any) -> TSchema:
|
|
138
|
+
return _node({"anyOf": [_strip_optional(schema) for schema in schemas]}, options)
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def Intersect(schemas: Iterable[Mapping[str, Any]], **options: Any) -> TSchema:
|
|
142
|
+
return _node({"allOf": [_strip_optional(schema) for schema in schemas]}, options)
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def Enum(values: Iterable[Any] | Mapping[str, Any], **options: Any) -> TSchema:
|
|
146
|
+
"""An enum of literal values (``anyOf`` of ``const``). Prefer :func:`StringEnum` for Google APIs."""
|
|
147
|
+
items = list(values.values()) if isinstance(values, Mapping) else list(values)
|
|
148
|
+
return _node({"anyOf": [Type.Literal(item) for item in items]}, options)
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def Record(key: Mapping[str, Any], value: Mapping[str, Any], **options: Any) -> TSchema:
|
|
152
|
+
base: dict[str, Any] = {"type": "object", "additionalProperties": _strip_optional(value)}
|
|
153
|
+
pattern = key.get("pattern") if isinstance(key, Mapping) else None
|
|
154
|
+
if pattern:
|
|
155
|
+
base = {"type": "object", "patternProperties": {pattern: _strip_optional(value)}}
|
|
156
|
+
return _node(base, options)
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def Optional(schema: Mapping[str, Any]) -> TSchema:
|
|
160
|
+
"""Mark a property of ``Type.Object`` as optional (not listed in ``required``)."""
|
|
161
|
+
node = TSchema(schema)
|
|
162
|
+
node.optional = True
|
|
163
|
+
return node
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def Nullable(schema: Mapping[str, Any], **options: Any) -> TSchema:
|
|
167
|
+
return _node({"anyOf": [_strip_optional(schema), {"type": "null"}]}, options)
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def Ref(ref: str, **options: Any) -> TSchema:
|
|
171
|
+
return _node({"$ref": ref}, options)
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def Unsafe(schema: Mapping[str, Any]) -> TSchema:
|
|
175
|
+
"""Wrap an arbitrary JSON Schema dict."""
|
|
176
|
+
return TSchema(schema)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def StringEnum(values: Sequence[str], *, description: str | None = None, default: str | None = None) -> TSchema:
|
|
180
|
+
"""A string enum schema compatible with Google's API and other providers that do not
|
|
181
|
+
support ``anyOf``/``const`` patterns."""
|
|
182
|
+
schema = TSchema({"type": "string", "enum": list(values)})
|
|
183
|
+
if description:
|
|
184
|
+
schema["description"] = description
|
|
185
|
+
if default:
|
|
186
|
+
schema["default"] = default
|
|
187
|
+
return schema
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _strip_optional(schema: Any) -> Any:
|
|
191
|
+
if isinstance(schema, TSchema) and schema.optional:
|
|
192
|
+
return TSchema(schema)
|
|
193
|
+
return schema
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _json_type_of(value: Any) -> str | None:
|
|
197
|
+
if value is None:
|
|
198
|
+
return "null"
|
|
199
|
+
if isinstance(value, bool):
|
|
200
|
+
return "boolean"
|
|
201
|
+
if isinstance(value, int):
|
|
202
|
+
return "integer"
|
|
203
|
+
if isinstance(value, float):
|
|
204
|
+
return "number"
|
|
205
|
+
if isinstance(value, str):
|
|
206
|
+
return "string"
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Pydantic support
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
_pydantic_schema_cache: dict[type, dict[str, Any]] = {}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def is_pydantic_model(value: Any) -> bool:
|
|
218
|
+
"""True for Pydantic ``BaseModel`` subclasses (without importing pydantic)."""
|
|
219
|
+
return isinstance(value, type) and hasattr(value, "model_json_schema") and hasattr(value, "model_validate")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def parameters_schema(parameters: Any) -> dict[str, Any]:
|
|
223
|
+
"""The JSON Schema for tool ``parameters`` (a schema dict or a Pydantic model class)."""
|
|
224
|
+
if isinstance(parameters, dict):
|
|
225
|
+
return parameters
|
|
226
|
+
if is_pydantic_model(parameters):
|
|
227
|
+
cached = _pydantic_schema_cache.get(parameters)
|
|
228
|
+
if cached is None:
|
|
229
|
+
cached = _inline_pydantic_schema(parameters.model_json_schema())
|
|
230
|
+
_pydantic_schema_cache[parameters] = cached
|
|
231
|
+
return cached
|
|
232
|
+
if isinstance(parameters, Mapping):
|
|
233
|
+
return dict(parameters)
|
|
234
|
+
raise TypeError(f"Tool parameters must be a JSON Schema dict or a Pydantic model, got {type(parameters)!r}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _inline_pydantic_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
238
|
+
"""Drop Pydantic's ``title`` noise and inline local ``$defs`` references.
|
|
239
|
+
|
|
240
|
+
Several providers (Google in particular) reject ``$ref``; inlining keeps the
|
|
241
|
+
schema portable. Recursive models keep their references.
|
|
242
|
+
"""
|
|
243
|
+
defs = schema.get("$defs", {})
|
|
244
|
+
named_schema_maps = ("properties", "patternProperties", "dependentSchemas")
|
|
245
|
+
recursive = False
|
|
246
|
+
|
|
247
|
+
def resolve(node: Any, seen: frozenset[str]) -> Any:
|
|
248
|
+
if isinstance(node, list):
|
|
249
|
+
return [resolve(item, seen) for item in node]
|
|
250
|
+
if not isinstance(node, dict):
|
|
251
|
+
return node
|
|
252
|
+
ref = node.get("$ref")
|
|
253
|
+
if isinstance(ref, str) and ref.startswith("#/$defs/"):
|
|
254
|
+
name = ref[len("#/$defs/") :]
|
|
255
|
+
target = defs.get(name)
|
|
256
|
+
if target is not None and name not in seen:
|
|
257
|
+
resolved = resolve(target, seen | {name})
|
|
258
|
+
extra = {k: resolve(v, seen) for k, v in node.items() if k not in ("$ref", "title")}
|
|
259
|
+
return {**resolved, **extra}
|
|
260
|
+
nonlocal recursive
|
|
261
|
+
recursive = True
|
|
262
|
+
return node
|
|
263
|
+
result: dict[str, Any] = {}
|
|
264
|
+
for key, value in node.items():
|
|
265
|
+
if key in ("$defs", "title"):
|
|
266
|
+
continue
|
|
267
|
+
if key in named_schema_maps and isinstance(value, dict):
|
|
268
|
+
result[key] = {name: resolve(sub, seen) for name, sub in value.items()}
|
|
269
|
+
else:
|
|
270
|
+
result[key] = resolve(value, seen)
|
|
271
|
+
return result
|
|
272
|
+
|
|
273
|
+
result = resolve(schema, frozenset())
|
|
274
|
+
if recursive:
|
|
275
|
+
result = dict(schema)
|
|
276
|
+
if "type" not in result:
|
|
277
|
+
result["type"] = "object"
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def clone_schema(schema: Any) -> Any:
|
|
282
|
+
"""Deep copy a JSON-compatible schema (drops dict subclasses and non-JSON values)."""
|
|
283
|
+
return json.loads(json.dumps(parameters_schema(schema) if not isinstance(schema, (list, str)) else schema))
|
pi_ai/utils/text.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Text helpers for message content and system prompts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..types import SystemMessage
|
|
9
|
+
|
|
10
|
+
__all__ = ["content_text", "get_system_message_text", "render_system_message_update", "append_text", "append_thinking"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ``block.text += delta`` copies the whole string on every delta because the attribute holds a
|
|
14
|
+
# second reference. Moving the string into a local first lets CPython resize it in place, which
|
|
15
|
+
# keeps accumulating a long streamed response linear instead of quadratic.
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def append_text(block: Any, delta: str) -> None:
|
|
19
|
+
"""Append a streamed delta to ``block.text`` in amortized O(len(delta))."""
|
|
20
|
+
text = block.text
|
|
21
|
+
block.text = ""
|
|
22
|
+
text += delta
|
|
23
|
+
block.text = text
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def append_thinking(block: Any, delta: str) -> None:
|
|
27
|
+
"""Append a streamed delta to ``block.thinking`` in amortized O(len(delta))."""
|
|
28
|
+
text = block.thinking
|
|
29
|
+
block.thinking = ""
|
|
30
|
+
text += delta
|
|
31
|
+
block.thinking = text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def content_text(content: str | Sequence[Any], separator: str = "\n") -> str:
|
|
35
|
+
"""Extract and join the text blocks of message content."""
|
|
36
|
+
if isinstance(content, str):
|
|
37
|
+
return content
|
|
38
|
+
return separator.join(block.text for block in content if block.type == "text")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_system_message_text(message: SystemMessage) -> str:
|
|
42
|
+
"""Render a system message as a complete prompt: its content followed by its sections."""
|
|
43
|
+
parts = [content_text(message.content)]
|
|
44
|
+
if message.sections:
|
|
45
|
+
parts.extend(text for text in message.sections.values() if text is not None)
|
|
46
|
+
return "\n\n".join(part for part in parts if part)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def render_system_message_update(message: SystemMessage) -> str:
|
|
50
|
+
"""Render a later system message for APIs that accept system messages mid-conversation.
|
|
51
|
+
|
|
52
|
+
Section changes are framed by name so the model can relate them to the leading
|
|
53
|
+
prompt. This framing is request-time only and may change between versions.
|
|
54
|
+
"""
|
|
55
|
+
parts: list[str] = []
|
|
56
|
+
text = content_text(message.content)
|
|
57
|
+
if text:
|
|
58
|
+
parts.append(text)
|
|
59
|
+
for name, value in (message.sections or {}).items():
|
|
60
|
+
parts.append(
|
|
61
|
+
f'Removed system prompt section "{name}".'
|
|
62
|
+
if value is None
|
|
63
|
+
else f'Updated system prompt section "{name}":\n\n{value}'
|
|
64
|
+
)
|
|
65
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Transcript helpers: system prompt and tool state carried by system messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..types import Context, Message, SystemMessage, Tool, ToolReference, TranscriptContext
|
|
11
|
+
from .schema import parameters_schema
|
|
12
|
+
from .text import content_text, get_system_message_text
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"TranscriptContext",
|
|
16
|
+
"create_initial_system_message",
|
|
17
|
+
"normalize_context",
|
|
18
|
+
"get_initial_system_message",
|
|
19
|
+
"without_initial_system_message",
|
|
20
|
+
"get_current_tools",
|
|
21
|
+
"get_current_system_message",
|
|
22
|
+
"get_current_system_prompt",
|
|
23
|
+
"collapse_system_messages",
|
|
24
|
+
"resolve_transcript",
|
|
25
|
+
"to_tool_declaration",
|
|
26
|
+
"declarations_equal",
|
|
27
|
+
"ToolStateChanges",
|
|
28
|
+
"get_tool_state_changes",
|
|
29
|
+
"get_declared_tools",
|
|
30
|
+
"has_tool_redefinitions",
|
|
31
|
+
"has_non_additive_tool_changes",
|
|
32
|
+
"TranscriptTools",
|
|
33
|
+
"resolve_transcript_tools",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_initial_system_message(system_prompt: str | None, tools: Sequence[Tool] | None) -> SystemMessage | None:
|
|
38
|
+
"""Build the leading system message for a prompt and tool set.
|
|
39
|
+
|
|
40
|
+
Returns ``None`` when both are empty, so an empty transcript stays empty.
|
|
41
|
+
"""
|
|
42
|
+
has_prompt = bool(system_prompt)
|
|
43
|
+
has_tools = bool(tools)
|
|
44
|
+
if not has_prompt and not has_tools:
|
|
45
|
+
return None
|
|
46
|
+
return SystemMessage(system_prompt or "", tools_added=list(tools) if has_tools else None, timestamp=0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def normalize_context(context: Context | Mapping[str, Any] | TranscriptContext) -> TranscriptContext:
|
|
50
|
+
"""Fold ``Context.system_prompt`` and ``Context.tools`` into a leading system message.
|
|
51
|
+
|
|
52
|
+
This is the entry point that produces a :class:`TranscriptContext`. Plain dicts
|
|
53
|
+
(``{"system_prompt": ..., "messages": [...], "tools": [...]}``, snake_case or pi's
|
|
54
|
+
camelCase keys) and dict messages are accepted and converted.
|
|
55
|
+
"""
|
|
56
|
+
if isinstance(context, TranscriptContext):
|
|
57
|
+
return context
|
|
58
|
+
if isinstance(context, Context):
|
|
59
|
+
system_prompt, tools, messages = context.system_prompt, context.tools, context.messages
|
|
60
|
+
else:
|
|
61
|
+
system_prompt = context.get("system_prompt", context.get("systemPrompt"))
|
|
62
|
+
tools = context.get("tools")
|
|
63
|
+
messages = context.get("messages") or []
|
|
64
|
+
messages = _coerce_messages(messages)
|
|
65
|
+
if tools:
|
|
66
|
+
tools = [_coerce_tool(tool) for tool in tools]
|
|
67
|
+
initial = create_initial_system_message(system_prompt, tools)
|
|
68
|
+
return TranscriptContext([initial, *messages] if initial else list(messages))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _coerce_messages(messages: Sequence[Any]) -> list[Message]:
|
|
72
|
+
if all(not isinstance(message, Mapping) for message in messages):
|
|
73
|
+
return messages if isinstance(messages, list) else list(messages)
|
|
74
|
+
from ..serialization import message_from_dict
|
|
75
|
+
|
|
76
|
+
return [message_from_dict(message) if isinstance(message, Mapping) else message for message in messages]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _coerce_tool(tool: Any) -> Tool:
|
|
80
|
+
if isinstance(tool, Mapping):
|
|
81
|
+
from ..serialization import tool_from_dict
|
|
82
|
+
|
|
83
|
+
return tool_from_dict(tool)
|
|
84
|
+
return tool
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_system(message: Any) -> bool:
|
|
88
|
+
return getattr(message, "role", None) == "system"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_initial_system_message(messages: Sequence[Any]) -> SystemMessage | None:
|
|
92
|
+
"""The leading system message, if the transcript starts with one."""
|
|
93
|
+
if messages and _is_system(messages[0]):
|
|
94
|
+
return messages[0]
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def without_initial_system_message(messages: list[Message]) -> list[Message]:
|
|
99
|
+
"""Drop the leading system message for APIs that carry the prompt outside the message list."""
|
|
100
|
+
return messages[1:] if get_initial_system_message(messages) else messages
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_current_tools(messages: Sequence[Any]) -> list[Tool]:
|
|
104
|
+
"""Resolve the tools available after applying every transcript delta in order."""
|
|
105
|
+
tools: dict[str, Tool] = {}
|
|
106
|
+
for message in messages:
|
|
107
|
+
if not _is_system(message):
|
|
108
|
+
continue
|
|
109
|
+
for removed in message.tools_removed or ():
|
|
110
|
+
tools.pop(removed.name, None)
|
|
111
|
+
for added in message.tools_added or ():
|
|
112
|
+
tools[added.name] = added
|
|
113
|
+
return list(tools.values())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_current_system_message(messages: Sequence[Any]) -> SystemMessage | None:
|
|
117
|
+
"""Replay every system message into one leading system message.
|
|
118
|
+
|
|
119
|
+
Later ``content`` is appended to the base prompt, ``sections`` are patched by
|
|
120
|
+
name, and tools are resolved with :func:`get_current_tools`.
|
|
121
|
+
"""
|
|
122
|
+
content: list[str] = []
|
|
123
|
+
sections: dict[str, str] = {}
|
|
124
|
+
timestamp: int | None = None
|
|
125
|
+
for message in messages:
|
|
126
|
+
if not _is_system(message):
|
|
127
|
+
continue
|
|
128
|
+
if timestamp is None:
|
|
129
|
+
timestamp = message.timestamp
|
|
130
|
+
text = content_text(message.content)
|
|
131
|
+
if text:
|
|
132
|
+
content.append(text)
|
|
133
|
+
for name, value in (message.sections or {}).items():
|
|
134
|
+
if value is None:
|
|
135
|
+
sections.pop(name, None)
|
|
136
|
+
else:
|
|
137
|
+
sections[name] = value
|
|
138
|
+
tools = get_current_tools(messages)
|
|
139
|
+
if timestamp is None and not tools:
|
|
140
|
+
return None
|
|
141
|
+
return SystemMessage(
|
|
142
|
+
"\n\n".join(content),
|
|
143
|
+
sections=dict(sections) if sections else None,
|
|
144
|
+
tools_added=tools if tools else None,
|
|
145
|
+
timestamp=timestamp if timestamp is not None else 0,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_current_system_prompt(messages: Sequence[Any]) -> str:
|
|
150
|
+
"""The current system prompt text after replaying every system message."""
|
|
151
|
+
message = get_current_system_message(messages)
|
|
152
|
+
return get_system_message_text(message) if message else ""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def collapse_system_messages(context: TranscriptContext) -> TranscriptContext:
|
|
156
|
+
"""Rebuild the transcript for APIs without mid-conversation system messages.
|
|
157
|
+
|
|
158
|
+
The replayed system message leads and every later system message is dropped.
|
|
159
|
+
"""
|
|
160
|
+
head = get_current_system_message(context.messages)
|
|
161
|
+
messages = [message for message in context.messages if not _is_system(message)]
|
|
162
|
+
return TranscriptContext([head, *messages] if head else messages)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def resolve_transcript(
|
|
166
|
+
context: TranscriptContext, supports_mid_convo_system_messages: bool | None
|
|
167
|
+
) -> TranscriptContext:
|
|
168
|
+
"""Keep later system messages in place when the model accepts them; otherwise collapse them."""
|
|
169
|
+
return context if supports_mid_convo_system_messages else collapse_system_messages(context)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def to_tool_declaration(tool: Tool) -> Tool:
|
|
173
|
+
"""Strip executable and display-only fields from a tool (e.g. an agent tool).
|
|
174
|
+
|
|
175
|
+
Parameters are deep-copied as plain JSON Schema (Pydantic models are converted).
|
|
176
|
+
"""
|
|
177
|
+
parameters = json.loads(json.dumps(parameters_schema(tool.parameters)))
|
|
178
|
+
constrained = tool.constrained_sampling
|
|
179
|
+
return Tool(
|
|
180
|
+
tool.name,
|
|
181
|
+
tool.description,
|
|
182
|
+
parameters,
|
|
183
|
+
constrained_sampling=json.loads(json.dumps(constrained)) if isinstance(constrained, dict) else constrained,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _declaration_key(tool: Tool) -> str:
|
|
188
|
+
constrained = tool.constrained_sampling
|
|
189
|
+
return json.dumps(
|
|
190
|
+
[tool.name, tool.description, parameters_schema(tool.parameters), constrained if constrained else None],
|
|
191
|
+
sort_keys=True,
|
|
192
|
+
default=str,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def declarations_equal(left: Tool, right: Tool) -> bool:
|
|
197
|
+
"""Whether two tools declare the same interface to the model."""
|
|
198
|
+
if left is right:
|
|
199
|
+
return True
|
|
200
|
+
return _declaration_key(left) == _declaration_key(right)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(slots=True)
|
|
204
|
+
class ToolStateChanges:
|
|
205
|
+
tools_added: list[Tool]
|
|
206
|
+
tools_removed: list[ToolReference]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_tool_state_changes(previous: Sequence[Tool], current: Sequence[Tool]) -> ToolStateChanges:
|
|
210
|
+
"""Compare two complete tool states. A changed definition is a removal followed by an addition."""
|
|
211
|
+
previous_keys = {tool.name: _declaration_key(tool) for tool in previous}
|
|
212
|
+
current_keys = {tool.name: _declaration_key(tool) for tool in current}
|
|
213
|
+
added = [to_tool_declaration(tool) for tool in current if previous_keys.get(tool.name) != current_keys[tool.name]]
|
|
214
|
+
removed = [ToolReference(tool.name) for tool in previous if current_keys.get(tool.name) != previous_keys[tool.name]]
|
|
215
|
+
return ToolStateChanges(added, removed)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def get_declared_tools(messages: Sequence[Any]) -> list[Tool]:
|
|
219
|
+
"""Every definition referenced by transcript tool state, in first-declaration order."""
|
|
220
|
+
definitions: dict[str, Tool] = {}
|
|
221
|
+
for message in messages:
|
|
222
|
+
if _is_system(message):
|
|
223
|
+
for tool in message.tools_added or ():
|
|
224
|
+
definitions[tool.name] = tool
|
|
225
|
+
return list(definitions.values())
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def has_tool_redefinitions(messages: Sequence[Any]) -> bool:
|
|
229
|
+
"""Whether a tool name was declared twice with different definitions (deprecated in pi)."""
|
|
230
|
+
declared: dict[str, Tool] = {}
|
|
231
|
+
for message in messages:
|
|
232
|
+
if not _is_system(message):
|
|
233
|
+
continue
|
|
234
|
+
for tool in message.tools_added or ():
|
|
235
|
+
previous = declared.get(tool.name)
|
|
236
|
+
if previous is not None and not declarations_equal(previous, tool):
|
|
237
|
+
return True
|
|
238
|
+
declared[tool.name] = tool
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def has_non_additive_tool_changes(messages: Sequence[Any]) -> bool:
|
|
243
|
+
"""Whether tool history contains a removal or same-name redeclaration."""
|
|
244
|
+
declared: set[str] = set()
|
|
245
|
+
for message in messages:
|
|
246
|
+
if not _is_system(message):
|
|
247
|
+
continue
|
|
248
|
+
if message.tools_removed:
|
|
249
|
+
return True
|
|
250
|
+
for tool in message.tools_added or ():
|
|
251
|
+
if tool.name in declared:
|
|
252
|
+
return True
|
|
253
|
+
declared.add(tool.name)
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass(slots=True)
|
|
258
|
+
class TranscriptTools:
|
|
259
|
+
#: Tools sent in the top-level request field.
|
|
260
|
+
request_tools: list[Tool]
|
|
261
|
+
#: Whether later system messages carry their own ``tools_added`` as in-place additions.
|
|
262
|
+
anchors_additions: bool
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def resolve_transcript_tools(messages: Sequence[Any], supports_tool_additions: bool) -> TranscriptTools:
|
|
266
|
+
"""Split tool declarations between the top-level request field and in-place additions."""
|
|
267
|
+
anchors = supports_tool_additions and not has_non_additive_tool_changes(messages)
|
|
268
|
+
if anchors:
|
|
269
|
+
initial = get_initial_system_message(messages)
|
|
270
|
+
return TranscriptTools(list(initial.tools_added or []) if initial else [], True)
|
|
271
|
+
return TranscriptTools(get_current_tools(messages), False)
|