pi-ai-python 1.3.0__py3-none-any.whl

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