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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. pi_agent/__init__.py +127 -0
  2. pi_agent/agent.py +617 -0
  3. pi_agent/agent_loop.py +820 -0
  4. pi_agent/proxy.py +269 -0
  5. pi_agent/py.typed +0 -0
  6. pi_agent/stream_fn.py +27 -0
  7. pi_agent/tools.py +327 -0
  8. pi_agent/types.py +394 -0
  9. pi_ai/__init__.py +263 -0
  10. pi_ai/abort.py +315 -0
  11. pi_ai/api/__init__.py +1 -0
  12. pi_ai/api/anthropic_messages.py +1782 -0
  13. pi_ai/api/azure_openai_config.py +138 -0
  14. pi_ai/api/azure_openai_responses.py +245 -0
  15. pi_ai/api/bedrock_converse_stream.py +1598 -0
  16. pi_ai/api/classifier_shared.py +211 -0
  17. pi_ai/api/cloudflare.py +36 -0
  18. pi_ai/api/cloudflare_workers_ai_system_one.py +80 -0
  19. pi_ai/api/constrained_sampling.py +279 -0
  20. pi_ai/api/github_copilot_headers.py +30 -0
  21. pi_ai/api/google_generative_ai.py +138 -0
  22. pi_ai/api/google_shared.py +927 -0
  23. pi_ai/api/google_vertex.py +234 -0
  24. pi_ai/api/lazy.py +156 -0
  25. pi_ai/api/llama_cpp_classify.py +449 -0
  26. pi_ai/api/mistral_conversations.py +756 -0
  27. pi_ai/api/openai_codex_responses.py +1545 -0
  28. pi_ai/api/openai_completions.py +1594 -0
  29. pi_ai/api/openai_decisions.py +210 -0
  30. pi_ai/api/openai_prompt_cache.py +19 -0
  31. pi_ai/api/openai_responses.py +418 -0
  32. pi_ai/api/openai_responses_shared.py +840 -0
  33. pi_ai/api/openai_sdk.py +204 -0
  34. pi_ai/api/openrouter_images.py +153 -0
  35. pi_ai/api/pi_messages.py +449 -0
  36. pi_ai/api/registry.py +103 -0
  37. pi_ai/api/simple_options.py +119 -0
  38. pi_ai/api/system_one_shared.py +148 -0
  39. pi_ai/api/transform_messages.py +211 -0
  40. pi_ai/api/typesafe_system_one.py +43 -0
  41. pi_ai/auth/__init__.py +1 -0
  42. pi_ai/auth/context.py +47 -0
  43. pi_ai/auth/credential_store.py +208 -0
  44. pi_ai/auth/helpers.py +123 -0
  45. pi_ai/auth/oauth/__init__.py +5 -0
  46. pi_ai/auth/oauth/_common.py +454 -0
  47. pi_ai/auth/oauth/anthropic.py +283 -0
  48. pi_ai/auth/oauth/callback_server.py +339 -0
  49. pi_ai/auth/oauth/device_code.py +127 -0
  50. pi_ai/auth/oauth/github_copilot.py +508 -0
  51. pi_ai/auth/oauth/kimi_coding.py +283 -0
  52. pi_ai/auth/oauth/load.py +64 -0
  53. pi_ai/auth/oauth/meta.py +238 -0
  54. pi_ai/auth/oauth/openai_chatgpt.py +321 -0
  55. pi_ai/auth/oauth/openai_codex.py +391 -0
  56. pi_ai/auth/oauth/openrouter.py +173 -0
  57. pi_ai/auth/oauth/pkce.py +23 -0
  58. pi_ai/auth/oauth/radius.py +311 -0
  59. pi_ai/auth/oauth/xai.py +220 -0
  60. pi_ai/auth/resolve.py +190 -0
  61. pi_ai/auth/types.py +292 -0
  62. pi_ai/cli.py +265 -0
  63. pi_ai/compat.py +651 -0
  64. pi_ai/env_api_keys.py +137 -0
  65. pi_ai/event_stream.py +329 -0
  66. pi_ai/models.py +1191 -0
  67. pi_ai/models_store.py +121 -0
  68. pi_ai/oauth.py +90 -0
  69. pi_ai/providers/__init__.py +1 -0
  70. pi_ai/providers/all.py +154 -0
  71. pi_ai/providers/amazon_bedrock.py +116 -0
  72. pi_ai/providers/ant_ling.py +23 -0
  73. pi_ai/providers/anthropic.py +105 -0
  74. pi_ai/providers/azure.py +103 -0
  75. pi_ai/providers/baseten.py +23 -0
  76. pi_ai/providers/catalog.py +167 -0
  77. pi_ai/providers/cerebras.py +23 -0
  78. pi_ai/providers/cloudflare_ai_gateway.py +30 -0
  79. pi_ai/providers/cloudflare_auth.py +112 -0
  80. pi_ai/providers/cloudflare_stream.py +84 -0
  81. pi_ai/providers/cloudflare_workers_ai.py +26 -0
  82. pi_ai/providers/data/amazon-bedrock.json +1 -0
  83. pi_ai/providers/data/ant-ling.json +1 -0
  84. pi_ai/providers/data/anthropic.json +1 -0
  85. pi_ai/providers/data/azure.json +1 -0
  86. pi_ai/providers/data/baseten.json +1 -0
  87. pi_ai/providers/data/cerebras.json +1 -0
  88. pi_ai/providers/data/cloudflare-ai-gateway.json +1 -0
  89. pi_ai/providers/data/cloudflare-workers-ai.json +1 -0
  90. pi_ai/providers/data/deepseek.json +1 -0
  91. pi_ai/providers/data/fireworks.json +1 -0
  92. pi_ai/providers/data/github-copilot.json +1 -0
  93. pi_ai/providers/data/google-vertex.json +1 -0
  94. pi_ai/providers/data/google.json +1 -0
  95. pi_ai/providers/data/groq.json +1 -0
  96. pi_ai/providers/data/huggingface.json +1 -0
  97. pi_ai/providers/data/kimi-coding.json +1 -0
  98. pi_ai/providers/data/manifest.json +1 -0
  99. pi_ai/providers/data/meta.json +1 -0
  100. pi_ai/providers/data/minimax-cn.json +1 -0
  101. pi_ai/providers/data/minimax.json +1 -0
  102. pi_ai/providers/data/mistral.json +1 -0
  103. pi_ai/providers/data/moonshotai-cn.json +1 -0
  104. pi_ai/providers/data/moonshotai.json +1 -0
  105. pi_ai/providers/data/nvidia.json +1 -0
  106. pi_ai/providers/data/openai-codex.json +1 -0
  107. pi_ai/providers/data/openai.json +1 -0
  108. pi_ai/providers/data/opencode-go.json +1 -0
  109. pi_ai/providers/data/opencode.json +1 -0
  110. pi_ai/providers/data/openrouter.json +1 -0
  111. pi_ai/providers/data/qwen-token-plan-cn.json +1 -0
  112. pi_ai/providers/data/qwen-token-plan-individual.json +1 -0
  113. pi_ai/providers/data/qwen-token-plan.json +1 -0
  114. pi_ai/providers/data/radius.json +1 -0
  115. pi_ai/providers/data/together.json +1 -0
  116. pi_ai/providers/data/typesafe.json +1 -0
  117. pi_ai/providers/data/vercel-ai-gateway.json +1 -0
  118. pi_ai/providers/data/xai.json +1 -0
  119. pi_ai/providers/data/xiaomi-token-plan-ams.json +1 -0
  120. pi_ai/providers/data/xiaomi-token-plan-cn.json +1 -0
  121. pi_ai/providers/data/xiaomi-token-plan-sgp.json +1 -0
  122. pi_ai/providers/data/xiaomi.json +1 -0
  123. pi_ai/providers/data/zai-coding-cn.json +1 -0
  124. pi_ai/providers/data/zai.json +1 -0
  125. pi_ai/providers/deepseek.py +23 -0
  126. pi_ai/providers/faux.py +680 -0
  127. pi_ai/providers/fireworks.py +26 -0
  128. pi_ai/providers/github_copilot.py +46 -0
  129. pi_ai/providers/google.py +23 -0
  130. pi_ai/providers/google_vertex.py +131 -0
  131. pi_ai/providers/groq.py +23 -0
  132. pi_ai/providers/huggingface.py +23 -0
  133. pi_ai/providers/kimi_coding.py +32 -0
  134. pi_ai/providers/meta.py +32 -0
  135. pi_ai/providers/minimax.py +23 -0
  136. pi_ai/providers/minimax_cn.py +23 -0
  137. pi_ai/providers/mistral.py +23 -0
  138. pi_ai/providers/moonshotai.py +23 -0
  139. pi_ai/providers/moonshotai_cn.py +23 -0
  140. pi_ai/providers/nvidia.py +23 -0
  141. pi_ai/providers/openai.py +44 -0
  142. pi_ai/providers/openai_codex.py +26 -0
  143. pi_ai/providers/opencode.py +36 -0
  144. pi_ai/providers/opencode_go.py +33 -0
  145. pi_ai/providers/opencode_headers.py +60 -0
  146. pi_ai/providers/openrouter.py +145 -0
  147. pi_ai/providers/openrouter_api.py +122 -0
  148. pi_ai/providers/qwen_token_plan.py +23 -0
  149. pi_ai/providers/qwen_token_plan_cn.py +23 -0
  150. pi_ai/providers/qwen_token_plan_individual.py +23 -0
  151. pi_ai/providers/radius.py +146 -0
  152. pi_ai/providers/radius_config.py +136 -0
  153. pi_ai/providers/together.py +23 -0
  154. pi_ai/providers/typesafe.py +22 -0
  155. pi_ai/providers/vercel_ai_gateway.py +25 -0
  156. pi_ai/providers/xai.py +32 -0
  157. pi_ai/providers/xiaomi.py +23 -0
  158. pi_ai/providers/xiaomi_token_plan_ams.py +23 -0
  159. pi_ai/providers/xiaomi_token_plan_cn.py +23 -0
  160. pi_ai/providers/xiaomi_token_plan_sgp.py +23 -0
  161. pi_ai/providers/zai.py +23 -0
  162. pi_ai/providers/zai_coding_cn.py +23 -0
  163. pi_ai/py.typed +0 -0
  164. pi_ai/serialization.py +477 -0
  165. pi_ai/session_resources.py +33 -0
  166. pi_ai/types.py +1003 -0
  167. pi_ai/utils/__init__.py +1 -0
  168. pi_ai/utils/callbacks.py +15 -0
  169. pi_ai/utils/diagnostics.py +70 -0
  170. pi_ai/utils/error_body.py +89 -0
  171. pi_ai/utils/estimate.py +125 -0
  172. pi_ai/utils/fastjson.py +63 -0
  173. pi_ai/utils/http.py +409 -0
  174. pi_ai/utils/json_parse.py +424 -0
  175. pi_ai/utils/misc.py +193 -0
  176. pi_ai/utils/model_operations.py +73 -0
  177. pi_ai/utils/models_error.py +35 -0
  178. pi_ai/utils/oauth_page.py +121 -0
  179. pi_ai/utils/overflow.py +87 -0
  180. pi_ai/utils/retry.py +193 -0
  181. pi_ai/utils/schema.py +283 -0
  182. pi_ai/utils/text.py +65 -0
  183. pi_ai/utils/transcript.py +271 -0
  184. pi_ai/utils/validation.py +813 -0
  185. pi_ai_python-1.3.0.dist-info/METADATA +167 -0
  186. pi_ai_python-1.3.0.dist-info/RECORD +189 -0
  187. pi_ai_python-1.3.0.dist-info/WHEEL +4 -0
  188. pi_ai_python-1.3.0.dist-info/entry_points.txt +2 -0
  189. pi_ai_python-1.3.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,813 @@
1
+ """Tool argument validation.
2
+
3
+ Arguments are coerced and validated against the tool's JSON Schema with a small
4
+ compiled validator (schemas compile once into closures and are cached), so no
5
+ third-party JSON Schema library is needed. Pydantic model parameters are validated
6
+ with ``model_validate`` and returned as model instances.
7
+
8
+ Supported keywords: ``type``, ``enum``, ``const``, ``properties``, ``required``,
9
+ ``additionalProperties``, ``patternProperties``, ``propertyNames``,
10
+ ``minProperties``/``maxProperties``, ``items``/``prefixItems``, ``contains``,
11
+ ``minItems``/``maxItems``, ``uniqueItems``, ``minLength``/``maxLength``,
12
+ ``pattern``, ``minimum``/``maximum``/``exclusiveMinimum``/``exclusiveMaximum``,
13
+ ``multipleOf``, ``allOf``/``anyOf``/``oneOf``/``not``, ``if``/``then``/``else``,
14
+ ``dependentRequired`` and local ``$ref`` (``#``, ``#/$defs/...``, ``#/definitions/...``).
15
+ ``format`` is treated as an annotation.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import math
22
+ import re
23
+ from collections import OrderedDict
24
+ from collections.abc import Callable, Sequence
25
+ from typing import Any
26
+
27
+ from ..types import Tool, ToolCall
28
+ from .schema import is_pydantic_model, parameters_schema
29
+
30
+ __all__ = [
31
+ "ToolValidationError",
32
+ "validate_tool_call",
33
+ "validate_tool_arguments",
34
+ "compile_schema",
35
+ "check_schema",
36
+ "schema_errors",
37
+ "coerce_with_json_schema",
38
+ ]
39
+
40
+
41
+ class ToolValidationError(ValueError):
42
+ """Raised when tool arguments do not match the tool's schema."""
43
+
44
+
45
+ # A compiled validator: (value, path, errors) -> ok. ``errors`` is None in check-only mode.
46
+ _Validator = Callable[[Any, tuple, "list[tuple[tuple, str, str]] | None"], bool]
47
+
48
+ _CACHE_LIMIT = 512
49
+ _validator_cache: OrderedDict[int, tuple[Any, _Validator]] = OrderedDict()
50
+
51
+
52
+ def compile_schema(schema: Any) -> _Validator:
53
+ """Compile (and cache) a validator for ``schema``."""
54
+ key = id(schema)
55
+ cached = _validator_cache.get(key)
56
+ if cached is not None and cached[0] is schema:
57
+ _validator_cache.move_to_end(key)
58
+ return cached[1]
59
+ validator = _Compiler(schema).compile(schema)
60
+ _validator_cache[key] = (schema, validator)
61
+ if len(_validator_cache) > _CACHE_LIMIT:
62
+ _validator_cache.popitem(last=False)
63
+ return validator
64
+
65
+
66
+ def check_schema(schema: Any, value: Any) -> bool:
67
+ return compile_schema(schema)(value, (), None)
68
+
69
+
70
+ def schema_errors(schema: Any, value: Any) -> list[tuple[str, str]]:
71
+ """Validation errors as ``(path, message)`` pairs; empty when valid."""
72
+ errors: list[tuple[tuple, str, str]] = []
73
+ compile_schema(schema)(value, (), errors)
74
+ return [(_format_path(path, keyword_extra), message) for path, keyword_extra, message in errors]
75
+
76
+
77
+ def _format_path(path: tuple, extra: str) -> str:
78
+ parts = [str(part) for part in path]
79
+ if extra:
80
+ parts.append(extra)
81
+ return ".".join(parts) if parts else "root"
82
+
83
+
84
+ def _json_type_name(value: Any) -> str:
85
+ if value is None:
86
+ return "null"
87
+ if isinstance(value, bool):
88
+ return "boolean"
89
+ if isinstance(value, int):
90
+ return "integer"
91
+ if isinstance(value, float):
92
+ return "number"
93
+ if isinstance(value, str):
94
+ return "string"
95
+ if isinstance(value, (list, tuple)):
96
+ return "array"
97
+ if isinstance(value, dict):
98
+ return "object"
99
+ return type(value).__name__
100
+
101
+
102
+ def _is_type(value: Any, json_type: str) -> bool:
103
+ match json_type:
104
+ case "string":
105
+ return isinstance(value, str)
106
+ case "number":
107
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
108
+ case "integer":
109
+ if isinstance(value, bool):
110
+ return False
111
+ if isinstance(value, int):
112
+ return True
113
+ return isinstance(value, float) and value.is_integer()
114
+ case "boolean":
115
+ return isinstance(value, bool)
116
+ case "null":
117
+ return value is None
118
+ case "array":
119
+ return isinstance(value, (list, tuple))
120
+ case "object":
121
+ return isinstance(value, dict)
122
+ return False
123
+
124
+
125
+ def _json_equal(left: Any, right: Any) -> bool:
126
+ if isinstance(left, bool) or isinstance(right, bool):
127
+ return type(left) is type(right) and left == right
128
+ return left == right
129
+
130
+
131
+ class _Compiler:
132
+ def __init__(self, root: Any) -> None:
133
+ self.root = root
134
+ self.refs: dict[str, _Validator] = {}
135
+
136
+ def compile(self, schema: Any) -> _Validator:
137
+ if schema is True or schema is None:
138
+ return _accept
139
+ if schema is False:
140
+ return _reject
141
+ if not isinstance(schema, dict):
142
+ return _accept
143
+
144
+ checks: list[_Validator] = []
145
+ ref = schema.get("$ref")
146
+ if isinstance(ref, str):
147
+ checks.append(self._ref(ref))
148
+
149
+ types = schema.get("type")
150
+ if types is not None:
151
+ checks.append(_type_check(types if isinstance(types, list) else [types]))
152
+
153
+ if "const" in schema:
154
+ checks.append(_const_check(schema["const"]))
155
+ enum = schema.get("enum")
156
+ if isinstance(enum, list):
157
+ checks.append(_enum_check(enum))
158
+
159
+ # strings
160
+ if any(key in schema for key in ("minLength", "maxLength", "pattern")):
161
+ checks.append(_string_check(schema.get("minLength"), schema.get("maxLength"), schema.get("pattern")))
162
+ # numbers
163
+ if any(key in schema for key in ("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf")):
164
+ checks.append(_number_check(schema))
165
+ # objects
166
+ if any(
167
+ key in schema
168
+ for key in (
169
+ "properties",
170
+ "required",
171
+ "additionalProperties",
172
+ "patternProperties",
173
+ "propertyNames",
174
+ "minProperties",
175
+ "maxProperties",
176
+ "dependentRequired",
177
+ )
178
+ ):
179
+ checks.append(self._object_check(schema))
180
+ # arrays
181
+ if any(key in schema for key in ("items", "prefixItems", "minItems", "maxItems", "uniqueItems", "contains")):
182
+ checks.append(self._array_check(schema))
183
+ # combinators
184
+ all_of = schema.get("allOf")
185
+ if isinstance(all_of, list):
186
+ subs = [self.compile(sub) for sub in all_of]
187
+
188
+ def all_of_check(value: Any, path: tuple, errors: Any, subs: list[_Validator] = subs) -> bool:
189
+ ok = True
190
+ for sub in subs:
191
+ if not sub(value, path, errors):
192
+ ok = False
193
+ if errors is None:
194
+ return False
195
+ return ok
196
+
197
+ checks.append(all_of_check)
198
+ any_of = schema.get("anyOf")
199
+ if isinstance(any_of, list):
200
+ checks.append(self._any_of(any_of))
201
+ one_of = schema.get("oneOf")
202
+ if isinstance(one_of, list):
203
+ checks.append(self._one_of(one_of))
204
+ not_schema = schema.get("not")
205
+ if not_schema is not None:
206
+ negated = self.compile(not_schema)
207
+
208
+ def not_check(value: Any, path: tuple, errors: Any) -> bool:
209
+ if negated(value, path, None):
210
+ if errors is not None:
211
+ errors.append((path, "", "must NOT be valid"))
212
+ return False
213
+ return True
214
+
215
+ checks.append(not_check)
216
+ if "if" in schema:
217
+ condition = self.compile(schema["if"])
218
+ then = self.compile(schema["then"]) if "then" in schema else _accept
219
+ otherwise = self.compile(schema["else"]) if "else" in schema else _accept
220
+
221
+ def if_check(value: Any, path: tuple, errors: Any) -> bool:
222
+ return (then if condition(value, path, None) else otherwise)(value, path, errors)
223
+
224
+ checks.append(if_check)
225
+
226
+ if not checks:
227
+ return _accept
228
+ if len(checks) == 1:
229
+ return checks[0]
230
+
231
+ def combined(value: Any, path: tuple, errors: Any, checks: list[_Validator] = checks) -> bool:
232
+ ok = True
233
+ for check in checks:
234
+ if not check(value, path, errors):
235
+ ok = False
236
+ if errors is None:
237
+ return False
238
+ return ok
239
+
240
+ return combined
241
+
242
+ def _ref(self, ref: str) -> _Validator:
243
+ compiled = self.refs.get(ref)
244
+ if compiled is not None:
245
+ return compiled
246
+ holder: list[_Validator] = []
247
+
248
+ def deferred(value: Any, path: tuple, errors: Any) -> bool:
249
+ return holder[0](value, path, errors)
250
+
251
+ self.refs[ref] = deferred
252
+ target = _resolve_pointer(self.root, ref)
253
+ holder.append(self.compile(target) if target is not None else _accept)
254
+ return deferred
255
+
256
+ def _object_check(self, schema: dict[str, Any]) -> _Validator:
257
+ properties: dict[str, Any] = schema.get("properties") or {}
258
+ compiled_props = {name: self.compile(sub) for name, sub in properties.items()}
259
+ required: Sequence[str] = schema.get("required") or ()
260
+ additional = schema.get("additionalProperties", True)
261
+ additional_validator = self.compile(additional) if isinstance(additional, dict) else None
262
+ pattern_props = [
263
+ (re.compile(pattern), self.compile(sub)) for pattern, sub in (schema.get("patternProperties") or {}).items()
264
+ ]
265
+ property_names = self.compile(schema["propertyNames"]) if "propertyNames" in schema else None
266
+ min_props = schema.get("minProperties")
267
+ max_props = schema.get("maxProperties")
268
+ dependent_required: dict[str, list[str]] = schema.get("dependentRequired") or {}
269
+
270
+ def object_check(value: Any, path: tuple, errors: Any) -> bool:
271
+ if not isinstance(value, dict):
272
+ return True
273
+ ok = True
274
+ for name in required:
275
+ if name not in value:
276
+ ok = False
277
+ if errors is None:
278
+ return False
279
+ errors.append((path, name, f"must have required property '{name}'"))
280
+ for name, item in value.items():
281
+ validator = compiled_props.get(name)
282
+ matched = validator is not None
283
+ if validator is not None and not validator(item, (*path, name), errors):
284
+ ok = False
285
+ if errors is None:
286
+ return False
287
+ for pattern, sub in pattern_props:
288
+ if pattern.search(name):
289
+ matched = True
290
+ if not sub(item, (*path, name), errors):
291
+ ok = False
292
+ if errors is None:
293
+ return False
294
+ if property_names is not None and not property_names(name, (*path, name), errors):
295
+ ok = False
296
+ if errors is None:
297
+ return False
298
+ if not matched:
299
+ if additional is False:
300
+ ok = False
301
+ if errors is None:
302
+ return False
303
+ errors.append((path, "", f"must NOT have additional properties ('{name}')"))
304
+ elif additional_validator is not None and not additional_validator(item, (*path, name), errors):
305
+ ok = False
306
+ if errors is None:
307
+ return False
308
+ if min_props is not None and len(value) < min_props:
309
+ ok = False
310
+ if errors is None:
311
+ return False
312
+ errors.append((path, "", f"must NOT have fewer than {min_props} properties"))
313
+ if max_props is not None and len(value) > max_props:
314
+ ok = False
315
+ if errors is None:
316
+ return False
317
+ errors.append((path, "", f"must NOT have more than {max_props} properties"))
318
+ for trigger, names in dependent_required.items():
319
+ if trigger in value:
320
+ for name in names:
321
+ if name not in value:
322
+ ok = False
323
+ if errors is None:
324
+ return False
325
+ errors.append((path, name, f"must have property '{name}' when '{trigger}' is present"))
326
+ return ok
327
+
328
+ return object_check
329
+
330
+ def _array_check(self, schema: dict[str, Any]) -> _Validator:
331
+ items = schema.get("items")
332
+ prefix = schema.get("prefixItems")
333
+ if isinstance(items, list): # draft-07 tuple form
334
+ prefix, items = items, schema.get("additionalItems", True)
335
+ prefix_validators = [self.compile(sub) for sub in prefix] if isinstance(prefix, list) else []
336
+ rest_validator = self.compile(items) if items is not None and items is not True else None
337
+ rest_forbidden = items is False
338
+ min_items = schema.get("minItems")
339
+ max_items = schema.get("maxItems")
340
+ unique = schema.get("uniqueItems") is True
341
+ contains = self.compile(schema["contains"]) if "contains" in schema else None
342
+
343
+ def array_check(value: Any, path: tuple, errors: Any) -> bool:
344
+ if not isinstance(value, (list, tuple)):
345
+ return True
346
+ ok = True
347
+ for index, item in enumerate(value):
348
+ if index < len(prefix_validators):
349
+ validator: _Validator | None = prefix_validators[index]
350
+ elif rest_forbidden:
351
+ ok = False
352
+ if errors is None:
353
+ return False
354
+ errors.append((path, "", f"must NOT have more than {len(prefix_validators)} items"))
355
+ break
356
+ else:
357
+ validator = rest_validator
358
+ if validator is not None and not validator(item, (*path, index), errors):
359
+ ok = False
360
+ if errors is None:
361
+ return False
362
+ if min_items is not None and len(value) < min_items:
363
+ ok = False
364
+ if errors is None:
365
+ return False
366
+ errors.append((path, "", f"must NOT have fewer than {min_items} items"))
367
+ if max_items is not None and len(value) > max_items:
368
+ ok = False
369
+ if errors is None:
370
+ return False
371
+ errors.append((path, "", f"must NOT have more than {max_items} items"))
372
+ if unique:
373
+ seen: list[str] = []
374
+ for item in value:
375
+ key = json.dumps(item, sort_keys=True, default=str)
376
+ if key in seen:
377
+ ok = False
378
+ if errors is None:
379
+ return False
380
+ errors.append((path, "", "must NOT have duplicate items"))
381
+ break
382
+ seen.append(key)
383
+ if contains is not None and not any(contains(item, path, None) for item in value):
384
+ ok = False
385
+ if errors is None:
386
+ return False
387
+ errors.append((path, "", "must contain at least 1 valid item"))
388
+ return ok
389
+
390
+ return array_check
391
+
392
+ def _any_of(self, schemas: list[Any]) -> _Validator:
393
+ subs = [self.compile(sub) for sub in schemas]
394
+
395
+ def any_of_check(value: Any, path: tuple, errors: Any) -> bool:
396
+ for sub in subs:
397
+ if sub(value, path, None):
398
+ return True
399
+ if errors is not None:
400
+ errors.append((path, "", "must match a schema in anyOf"))
401
+ return False
402
+
403
+ return any_of_check
404
+
405
+ def _one_of(self, schemas: list[Any]) -> _Validator:
406
+ subs = [self.compile(sub) for sub in schemas]
407
+
408
+ def one_of_check(value: Any, path: tuple, errors: Any) -> bool:
409
+ matches = sum(1 for sub in subs if sub(value, path, None))
410
+ if matches == 1:
411
+ return True
412
+ if errors is not None:
413
+ errors.append((path, "", "must match exactly one schema in oneOf"))
414
+ return False
415
+
416
+ return one_of_check
417
+
418
+
419
+ def _accept(value: Any, path: tuple, errors: Any) -> bool:
420
+ return True
421
+
422
+
423
+ def _reject(value: Any, path: tuple, errors: Any) -> bool:
424
+ if errors is not None:
425
+ errors.append((path, "", "boolean schema is false"))
426
+ return False
427
+
428
+
429
+ def _type_check(types: list[str]) -> _Validator:
430
+ if len(types) == 1:
431
+ json_type = types[0]
432
+
433
+ def single_type_check(value: Any, path: tuple, errors: Any) -> bool:
434
+ if _is_type(value, json_type):
435
+ return True
436
+ if errors is not None:
437
+ errors.append((path, "", f"must be {json_type}"))
438
+ return False
439
+
440
+ return single_type_check
441
+
442
+ label = ",".join(types)
443
+
444
+ def type_check(value: Any, path: tuple, errors: Any) -> bool:
445
+ for json_type in types:
446
+ if _is_type(value, json_type):
447
+ return True
448
+ if errors is not None:
449
+ errors.append((path, "", f"must be {label}"))
450
+ return False
451
+
452
+ return type_check
453
+
454
+
455
+ def _const_check(expected: Any) -> _Validator:
456
+ def const_check(value: Any, path: tuple, errors: Any) -> bool:
457
+ if _json_equal(value, expected):
458
+ return True
459
+ if errors is not None:
460
+ errors.append((path, "", f"must be equal to constant {json.dumps(expected, default=str)}"))
461
+ return False
462
+
463
+ return const_check
464
+
465
+
466
+ def _enum_check(options: list[Any]) -> _Validator:
467
+ def enum_check(value: Any, path: tuple, errors: Any) -> bool:
468
+ for option in options:
469
+ if _json_equal(value, option):
470
+ return True
471
+ if errors is not None:
472
+ allowed = ", ".join(json.dumps(option, default=str) for option in options)
473
+ errors.append((path, "", f"must be equal to one of the allowed values: {allowed}"))
474
+ return False
475
+
476
+ return enum_check
477
+
478
+
479
+ def _string_check(min_length: int | None, max_length: int | None, pattern: str | None) -> _Validator:
480
+ compiled = re.compile(pattern) if pattern else None
481
+
482
+ def string_check(value: Any, path: tuple, errors: Any) -> bool:
483
+ if not isinstance(value, str):
484
+ return True
485
+ ok = True
486
+ if min_length is not None and len(value) < min_length:
487
+ ok = False
488
+ if errors is None:
489
+ return False
490
+ errors.append((path, "", f"must NOT have fewer than {min_length} characters"))
491
+ if max_length is not None and len(value) > max_length:
492
+ ok = False
493
+ if errors is None:
494
+ return False
495
+ errors.append((path, "", f"must NOT have more than {max_length} characters"))
496
+ if compiled is not None and compiled.search(value) is None:
497
+ ok = False
498
+ if errors is None:
499
+ return False
500
+ errors.append((path, "", f'must match pattern "{pattern}"'))
501
+ return ok
502
+
503
+ return string_check
504
+
505
+
506
+ def _number_check(schema: dict[str, Any]) -> _Validator:
507
+ minimum = schema.get("minimum")
508
+ maximum = schema.get("maximum")
509
+ exclusive_minimum = schema.get("exclusiveMinimum")
510
+ exclusive_maximum = schema.get("exclusiveMaximum")
511
+ multiple_of = schema.get("multipleOf")
512
+ # draft-04 boolean form
513
+ if exclusive_minimum is True:
514
+ exclusive_minimum, minimum = minimum, None
515
+ elif exclusive_minimum is False:
516
+ exclusive_minimum = None
517
+ if exclusive_maximum is True:
518
+ exclusive_maximum, maximum = maximum, None
519
+ elif exclusive_maximum is False:
520
+ exclusive_maximum = None
521
+
522
+ def number_check(value: Any, path: tuple, errors: Any) -> bool:
523
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
524
+ return True
525
+ failures: list[str] = []
526
+ if minimum is not None and value < minimum:
527
+ failures.append(f"must be >= {minimum}")
528
+ if maximum is not None and value > maximum:
529
+ failures.append(f"must be <= {maximum}")
530
+ if exclusive_minimum is not None and value <= exclusive_minimum:
531
+ failures.append(f"must be > {exclusive_minimum}")
532
+ if exclusive_maximum is not None and value >= exclusive_maximum:
533
+ failures.append(f"must be < {exclusive_maximum}")
534
+ if multiple_of:
535
+ quotient = value / multiple_of
536
+ if not math.isclose(quotient, round(quotient), rel_tol=0, abs_tol=1e-9):
537
+ failures.append(f"must be multiple of {multiple_of}")
538
+ if not failures:
539
+ return True
540
+ if errors is not None:
541
+ errors.extend((path, "", failure) for failure in failures)
542
+ return False
543
+
544
+ return number_check
545
+
546
+
547
+ def _resolve_pointer(root: Any, ref: str) -> Any:
548
+ if ref == "#":
549
+ return root
550
+ if not ref.startswith("#/"):
551
+ return None
552
+ node = root
553
+ for raw in ref[2:].split("/"):
554
+ part = raw.replace("~1", "/").replace("~0", "~")
555
+ if isinstance(node, dict) and part in node:
556
+ node = node[part]
557
+ elif isinstance(node, list) and part.isdigit() and int(part) < len(node):
558
+ node = node[int(part)]
559
+ else:
560
+ return None
561
+ return node
562
+
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # Coercion (mirrors pi's coerceWithJsonSchema / TypeBox Value.Convert)
566
+ # ---------------------------------------------------------------------------
567
+
568
+
569
+ def _schema_types(schema: dict[str, Any]) -> list[str]:
570
+ types = schema.get("type")
571
+ if isinstance(types, str):
572
+ return [types]
573
+ if isinstance(types, list):
574
+ return [t for t in types if isinstance(t, str)]
575
+ return []
576
+
577
+
578
+ def _coerce_primitive(value: Any, json_type: str) -> Any:
579
+ match json_type:
580
+ case "number":
581
+ if value is None:
582
+ return 0
583
+ if isinstance(value, str) and value.strip():
584
+ try:
585
+ parsed = float(value)
586
+ except ValueError:
587
+ return value
588
+ if math.isfinite(parsed):
589
+ return int(parsed) if parsed.is_integer() and _looks_integral(value) else parsed
590
+ if isinstance(value, bool):
591
+ return 1 if value else 0
592
+ return value
593
+ case "integer":
594
+ if value is None:
595
+ return 0
596
+ if isinstance(value, str) and value.strip():
597
+ try:
598
+ parsed = float(value)
599
+ except ValueError:
600
+ return value
601
+ if math.isfinite(parsed) and parsed.is_integer():
602
+ return int(parsed)
603
+ if isinstance(value, bool):
604
+ return 1 if value else 0
605
+ return value
606
+ case "boolean":
607
+ if value is None:
608
+ return False
609
+ if value == "true":
610
+ return True
611
+ if value == "false":
612
+ return False
613
+ if not isinstance(value, bool) and isinstance(value, (int, float)):
614
+ if value == 1:
615
+ return True
616
+ if value == 0:
617
+ return False
618
+ return value
619
+ case "string":
620
+ if value is None:
621
+ return ""
622
+ if isinstance(value, bool):
623
+ return "true" if value else "false"
624
+ if isinstance(value, (int, float)):
625
+ return _number_to_string(value)
626
+ return value
627
+ case "null":
628
+ if value == "" or (not isinstance(value, bool) and value == 0) or value is False:
629
+ return None
630
+ return value
631
+ return value
632
+
633
+
634
+ def _looks_integral(text: str) -> bool:
635
+ stripped = text.strip()
636
+ return "." not in stripped and "e" not in stripped.lower()
637
+
638
+
639
+ def _number_to_string(value: float) -> str:
640
+ if isinstance(value, float) and value.is_integer() and abs(value) < 1e21:
641
+ return str(int(value))
642
+ return json.dumps(value)
643
+
644
+
645
+ def _coerce_union(value: Any, schemas: list[Any]) -> Any:
646
+ for schema in schemas:
647
+ if check_schema(schema, value):
648
+ return value
649
+ for schema in schemas:
650
+ candidate = coerce_with_json_schema(_json_copy(value), schema)
651
+ if check_schema(schema, candidate):
652
+ return candidate
653
+ return value
654
+
655
+
656
+ def coerce_with_json_schema(value: Any, schema: Any) -> Any:
657
+ """Coerce primitive values toward the types a JSON Schema expects (in place for containers)."""
658
+ if not isinstance(schema, dict):
659
+ return value
660
+ next_value = value
661
+ all_of = schema.get("allOf")
662
+ if isinstance(all_of, list):
663
+ for nested in all_of:
664
+ next_value = coerce_with_json_schema(next_value, nested)
665
+ any_of = schema.get("anyOf")
666
+ if isinstance(any_of, list):
667
+ next_value = _coerce_union(next_value, any_of)
668
+ one_of = schema.get("oneOf")
669
+ if isinstance(one_of, list):
670
+ next_value = _coerce_union(next_value, one_of)
671
+
672
+ types = _schema_types(schema)
673
+ matches_union_member = len(types) > 1 and any(_is_type(next_value, t) for t in types)
674
+ if types and not matches_union_member and not (len(types) == 1 and _is_type(next_value, types[0])):
675
+ for json_type in types:
676
+ candidate = _coerce_primitive(next_value, json_type)
677
+ if type(candidate) is not type(next_value) or candidate != next_value:
678
+ next_value = candidate
679
+ break
680
+
681
+ if "object" in types and isinstance(next_value, dict):
682
+ properties = schema.get("properties")
683
+ defined = set(properties) if isinstance(properties, dict) else set()
684
+ if isinstance(properties, dict):
685
+ for key, sub in properties.items():
686
+ if key in next_value:
687
+ next_value[key] = coerce_with_json_schema(next_value[key], sub)
688
+ additional = schema.get("additionalProperties")
689
+ if isinstance(additional, dict):
690
+ for key in list(next_value):
691
+ if key not in defined:
692
+ next_value[key] = coerce_with_json_schema(next_value[key], additional)
693
+ if "array" in types and isinstance(next_value, list):
694
+ items = schema.get("items")
695
+ prefix = schema.get("prefixItems")
696
+ if isinstance(items, list):
697
+ prefix = items
698
+ if isinstance(prefix, list):
699
+ for index in range(min(len(prefix), len(next_value))):
700
+ next_value[index] = coerce_with_json_schema(next_value[index], prefix[index])
701
+ elif isinstance(items, dict):
702
+ for index in range(len(next_value)):
703
+ next_value[index] = coerce_with_json_schema(next_value[index], items)
704
+ return next_value
705
+
706
+
707
+ def _normalize_optional_nulls(value: Any, schema: Any) -> None:
708
+ """Drop ``null`` values for optional properties whose schema does not accept null."""
709
+ if not isinstance(schema, dict):
710
+ return
711
+ if isinstance(value, list):
712
+ items = schema.get("items")
713
+ if isinstance(items, list):
714
+ for item, sub in zip(value, items, strict=False):
715
+ _normalize_optional_nulls(item, sub)
716
+ elif isinstance(items, dict):
717
+ for item in value:
718
+ _normalize_optional_nulls(item, items)
719
+ return
720
+ properties = schema.get("properties")
721
+ if not isinstance(value, dict) or not isinstance(properties, dict):
722
+ return
723
+ required = set(schema.get("required") or ())
724
+ for key, sub in properties.items():
725
+ if key not in value:
726
+ continue
727
+ if (
728
+ value[key] is None
729
+ and key not in required
730
+ and not (isinstance(sub, dict) and isinstance(sub.get("$ref"), str))
731
+ and not check_schema(sub, None)
732
+ ):
733
+ del value[key]
734
+ else:
735
+ _normalize_optional_nulls(value[key], sub)
736
+
737
+
738
+ def _json_copy(value: Any) -> Any:
739
+ if isinstance(value, dict):
740
+ return {key: _json_copy(item) for key, item in value.items()}
741
+ if isinstance(value, list):
742
+ return [_json_copy(item) for item in value]
743
+ return value
744
+
745
+
746
+ # ---------------------------------------------------------------------------
747
+ # Public tool validation
748
+ # ---------------------------------------------------------------------------
749
+
750
+
751
+ def validate_tool_call(tools: Sequence[Tool], tool_call: ToolCall) -> Any:
752
+ """Find the tool by name and validate the call's arguments against its schema.
753
+
754
+ Raises :class:`ToolValidationError` when the tool is unknown or validation fails.
755
+ """
756
+ for tool in tools:
757
+ if tool.name == tool_call.name:
758
+ return validate_tool_arguments(tool, tool_call)
759
+ raise ToolValidationError(f'Tool "{tool_call.name}" not found')
760
+
761
+
762
+ def validate_tool_arguments(tool: Tool, tool_call: ToolCall) -> Any:
763
+ """Validate (and coerce) tool call arguments against the tool's parameter schema.
764
+
765
+ Returns the validated arguments: a ``dict`` for JSON Schema parameters, or a model
766
+ instance for Pydantic parameters. Raises :class:`ToolValidationError` with a
767
+ readable message listing every problem.
768
+ """
769
+ if tool_call.arguments_error:
770
+ raise ToolValidationError(f'Invalid arguments for tool "{tool_call.name}": {tool_call.arguments_error}')
771
+
772
+ parameters = tool.parameters
773
+ if is_pydantic_model(parameters):
774
+ return _validate_pydantic(parameters, tool_call)
775
+
776
+ schema = parameters_schema(parameters)
777
+ args = _json_copy(tool_call.arguments)
778
+ _normalize_optional_nulls(args, schema)
779
+ coerced = coerce_with_json_schema(args, schema)
780
+ validator = compile_schema(schema)
781
+ if validator(coerced, (), None):
782
+ return coerced
783
+ if coerced is not args and validator(args, (), None):
784
+ return args
785
+
786
+ raw_errors: list[tuple[tuple, str, str]] = []
787
+ validator(coerced, (), raw_errors)
788
+ lines = [f" - {_format_path(path, extra)}: {message}" for path, extra, message in raw_errors]
789
+ details = "\n".join(lines) or "Unknown validation error"
790
+ received = json.dumps(tool_call.arguments, indent=2, ensure_ascii=False, default=str)
791
+ raise ToolValidationError(
792
+ f'Validation failed for tool "{tool_call.name}":\n{details}\n\nReceived arguments:\n{received}'
793
+ )
794
+
795
+
796
+ def _validate_pydantic(model_class: Any, tool_call: ToolCall) -> Any:
797
+ try:
798
+ return model_class.model_validate(tool_call.arguments)
799
+ except Exception as error: # pydantic.ValidationError
800
+ errors_fn = getattr(error, "errors", None)
801
+ if not callable(errors_fn):
802
+ raise
803
+ lines = []
804
+ items: Any = errors_fn()
805
+ for item in items:
806
+ location = ".".join(str(part) for part in item.get("loc", ())) or "root"
807
+ lines.append(f" - {location}: {item.get('msg', 'invalid')}")
808
+ received = json.dumps(tool_call.arguments, indent=2, ensure_ascii=False, default=str)
809
+ raise ToolValidationError(
810
+ f'Validation failed for tool "{tool_call.name}":\n'
811
+ + ("\n".join(lines) or "Unknown validation error")
812
+ + f"\n\nReceived arguments:\n{received}"
813
+ ) from error