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