schemarouter 0.2.0a1__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.
- schemarouter/__init__.py +88 -0
- schemarouter/_version.py +8 -0
- schemarouter/adapters/__init__.py +29 -0
- schemarouter/adapters/base.py +71 -0
- schemarouter/adapters/mcp.py +179 -0
- schemarouter/adapters/openapi.py +418 -0
- schemarouter/adapters/optimade.py +656 -0
- schemarouter/adapters/python.py +188 -0
- schemarouter/analyzers/__init__.py +3 -0
- schemarouter/analyzers/model.py +185 -0
- schemarouter/errors.py +50 -0
- schemarouter/executor.py +207 -0
- schemarouter/ingestion.py +348 -0
- schemarouter/integrations/__init__.py +3 -0
- schemarouter/integrations/langchain.py +96 -0
- schemarouter/models.py +154 -0
- schemarouter/planner.py +253 -0
- schemarouter/policy.py +51 -0
- schemarouter/proposals.py +391 -0
- schemarouter/py.typed +0 -0
- schemarouter/registry.py +84 -0
- schemarouter/runs.py +77 -0
- schemarouter/runtime.py +686 -0
- schemarouter/validation.py +96 -0
- schemarouter-0.2.0a1.dist-info/METADATA +297 -0
- schemarouter-0.2.0a1.dist-info/RECORD +28 -0
- schemarouter-0.2.0a1.dist-info/WHEEL +4 -0
- schemarouter-0.2.0a1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urljoin, urlparse
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from .adapters.base import AdapterContext, AdapterLoadResult, AdapterRegistry, SourceAdapter
|
|
12
|
+
from .adapters.mcp import MCPRemoteInvoker, inspect_mcp_url
|
|
13
|
+
from .adapters.openapi import (
|
|
14
|
+
OpenAPIRemoteInvoker,
|
|
15
|
+
resolve_openapi_base_url,
|
|
16
|
+
same_origin,
|
|
17
|
+
tool_from_openapi,
|
|
18
|
+
)
|
|
19
|
+
from .adapters.optimade import OPTIMADESourceAdapter
|
|
20
|
+
from .errors import SchemaSourceError, UnsupportedSchemaSourceError
|
|
21
|
+
from .executor import RegistryExecutor
|
|
22
|
+
from .models import ToolSpec
|
|
23
|
+
from .registry import ToolRegistry
|
|
24
|
+
|
|
25
|
+
SourceKind = str
|
|
26
|
+
|
|
27
|
+
_MAX_SCHEMA_BYTES = 5 * 1024 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _slug(value: str) -> str:
|
|
31
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "_", value.strip()).strip("_.-").lower()
|
|
32
|
+
return slug or "remote_tool"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _name_from_url(url: str) -> str:
|
|
36
|
+
parsed = urlparse(url)
|
|
37
|
+
leaf = parsed.path.rstrip("/").rsplit("/", 1)[-1]
|
|
38
|
+
if leaf and "." in leaf:
|
|
39
|
+
leaf = leaf.rsplit(".", 1)[0]
|
|
40
|
+
return _slug(leaf or parsed.hostname or "remote_tool")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validate_url(url: str) -> None:
|
|
44
|
+
parsed = urlparse(url)
|
|
45
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
46
|
+
raise SchemaSourceError("schema URL must be an absolute http(s) URL")
|
|
47
|
+
if parsed.username or parsed.password:
|
|
48
|
+
raise SchemaSourceError("schema URL must not contain credentials")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_openapi_text(text: str) -> dict[str, Any] | None:
|
|
52
|
+
value: object
|
|
53
|
+
try:
|
|
54
|
+
value = json.loads(text)
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
try:
|
|
57
|
+
value = yaml.safe_load(text)
|
|
58
|
+
except yaml.YAMLError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
if not isinstance(value, dict):
|
|
62
|
+
return None
|
|
63
|
+
version = value.get("openapi")
|
|
64
|
+
if not isinstance(version, str) or not version.startswith("3."):
|
|
65
|
+
return None
|
|
66
|
+
if not isinstance(value.get("paths"), dict):
|
|
67
|
+
return None
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _fetch_with_safe_redirects(
|
|
72
|
+
client: httpx.AsyncClient,
|
|
73
|
+
url: str,
|
|
74
|
+
*,
|
|
75
|
+
headers: dict[str, str] | None,
|
|
76
|
+
max_redirects: int = 5,
|
|
77
|
+
) -> httpx.Response:
|
|
78
|
+
current = url
|
|
79
|
+
initial = url
|
|
80
|
+
for _ in range(max_redirects + 1):
|
|
81
|
+
async with client.stream(
|
|
82
|
+
"GET",
|
|
83
|
+
current,
|
|
84
|
+
headers=headers,
|
|
85
|
+
follow_redirects=False,
|
|
86
|
+
) as response:
|
|
87
|
+
if response.is_redirect:
|
|
88
|
+
location = response.headers.get("location")
|
|
89
|
+
if not location:
|
|
90
|
+
raise SchemaSourceError("schema redirect response is missing Location")
|
|
91
|
+
target = urljoin(current, location)
|
|
92
|
+
_validate_url(target)
|
|
93
|
+
if not same_origin(initial, target):
|
|
94
|
+
raise SchemaSourceError("cross-origin schema redirects are not allowed")
|
|
95
|
+
current = target
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
response.raise_for_status()
|
|
99
|
+
content_length = response.headers.get("content-length")
|
|
100
|
+
if content_length is not None:
|
|
101
|
+
try:
|
|
102
|
+
declared_size = int(content_length)
|
|
103
|
+
except ValueError:
|
|
104
|
+
declared_size = None
|
|
105
|
+
if declared_size is not None and declared_size > _MAX_SCHEMA_BYTES:
|
|
106
|
+
raise SchemaSourceError(
|
|
107
|
+
f"OpenAPI document exceeds {_MAX_SCHEMA_BYTES} byte safety limit"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
chunks: list[bytes] = []
|
|
111
|
+
total = 0
|
|
112
|
+
async for chunk in response.aiter_bytes():
|
|
113
|
+
total += len(chunk)
|
|
114
|
+
if total > _MAX_SCHEMA_BYTES:
|
|
115
|
+
raise SchemaSourceError(
|
|
116
|
+
f"OpenAPI document exceeds {_MAX_SCHEMA_BYTES} byte safety limit"
|
|
117
|
+
)
|
|
118
|
+
chunks.append(chunk)
|
|
119
|
+
|
|
120
|
+
return httpx.Response(
|
|
121
|
+
status_code=response.status_code,
|
|
122
|
+
headers=response.headers,
|
|
123
|
+
content=b"".join(chunks),
|
|
124
|
+
request=response.request,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
raise SchemaSourceError("schema URL exceeded the redirect limit")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class OpenAPISourceAdapter:
|
|
131
|
+
kind = "openapi"
|
|
132
|
+
priority = 100
|
|
133
|
+
|
|
134
|
+
async def load(self, context: AdapterContext) -> AdapterLoadResult | None:
|
|
135
|
+
owns_client = context.http_client is None
|
|
136
|
+
client = context.http_client or httpx.AsyncClient(
|
|
137
|
+
timeout=context.timeout,
|
|
138
|
+
follow_redirects=False,
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
response = await _fetch_with_safe_redirects(
|
|
142
|
+
client,
|
|
143
|
+
context.url,
|
|
144
|
+
headers=context.schema_headers,
|
|
145
|
+
)
|
|
146
|
+
document = _parse_openapi_text(response.text)
|
|
147
|
+
except SchemaSourceError:
|
|
148
|
+
raise
|
|
149
|
+
except Exception: # noqa: BLE001
|
|
150
|
+
return None
|
|
151
|
+
finally:
|
|
152
|
+
if owns_client:
|
|
153
|
+
await client.aclose()
|
|
154
|
+
|
|
155
|
+
if document is None:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
resolved_schema_url = str(response.url)
|
|
159
|
+
inferred_name = context.name or _slug(
|
|
160
|
+
str((document.get("info") or {}).get("title") or _name_from_url(context.url))
|
|
161
|
+
)
|
|
162
|
+
tool = tool_from_openapi(inferred_name, document, namespace=context.namespace)
|
|
163
|
+
try:
|
|
164
|
+
suggested_base_url = resolve_openapi_base_url(document, resolved_schema_url)
|
|
165
|
+
except ValueError as exc:
|
|
166
|
+
if context.base_url is None:
|
|
167
|
+
raise SchemaSourceError(
|
|
168
|
+
"OpenAPI document declared an unsafe or unsupported server URL"
|
|
169
|
+
) from exc
|
|
170
|
+
suggested_base_url = None
|
|
171
|
+
|
|
172
|
+
tool.metadata.update(
|
|
173
|
+
{
|
|
174
|
+
"source_url": context.url,
|
|
175
|
+
"resolved_schema_url": resolved_schema_url,
|
|
176
|
+
"suggested_base_url": suggested_base_url,
|
|
177
|
+
"remote": True,
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
selected_base_url = context.base_url or suggested_base_url
|
|
182
|
+
auto_bind_allowed = context.base_url is not None or (
|
|
183
|
+
suggested_base_url is not None
|
|
184
|
+
and same_origin(suggested_base_url, resolved_schema_url)
|
|
185
|
+
)
|
|
186
|
+
invoker = None
|
|
187
|
+
if auto_bind_allowed:
|
|
188
|
+
if selected_base_url is None:
|
|
189
|
+
raise SchemaSourceError("no approved OpenAPI base URL is available")
|
|
190
|
+
try:
|
|
191
|
+
invoker = OpenAPIRemoteInvoker(
|
|
192
|
+
tool,
|
|
193
|
+
selected_base_url,
|
|
194
|
+
trusted_headers=context.trusted_headers,
|
|
195
|
+
timeout=context.timeout,
|
|
196
|
+
)
|
|
197
|
+
except ValueError as exc:
|
|
198
|
+
raise SchemaSourceError(
|
|
199
|
+
"OpenAPI execution base URL or trusted headers are invalid"
|
|
200
|
+
) from exc
|
|
201
|
+
|
|
202
|
+
if invoker is not None:
|
|
203
|
+
tool.metadata.update(
|
|
204
|
+
{
|
|
205
|
+
"execution_bound": True,
|
|
206
|
+
"approved_base_url": selected_base_url,
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
tool.metadata.update(
|
|
211
|
+
{
|
|
212
|
+
"execution_bound": False,
|
|
213
|
+
"requires_explicit_base_url": True,
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return AdapterLoadResult(tool=tool, invoker=invoker)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class MCPSourceAdapter:
|
|
221
|
+
kind = "mcp"
|
|
222
|
+
priority = 80
|
|
223
|
+
|
|
224
|
+
async def load(self, context: AdapterContext) -> AdapterLoadResult | None:
|
|
225
|
+
if context.base_url is not None:
|
|
226
|
+
raise SchemaSourceError("base_url is not valid for MCP sources")
|
|
227
|
+
if context.trusted_headers:
|
|
228
|
+
raise SchemaSourceError(
|
|
229
|
+
"MCP custom trusted headers are not wired in v0.2; "
|
|
230
|
+
"use an explicit transport integration"
|
|
231
|
+
)
|
|
232
|
+
try:
|
|
233
|
+
tool = await inspect_mcp_url(
|
|
234
|
+
context.url,
|
|
235
|
+
server_name=context.name,
|
|
236
|
+
namespace=context.namespace,
|
|
237
|
+
)
|
|
238
|
+
except Exception: # noqa: BLE001
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
tool.metadata["remote"] = True
|
|
242
|
+
return AdapterLoadResult(tool=tool, invoker=MCPRemoteInvoker(context.url))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def default_adapter_registry() -> AdapterRegistry:
|
|
246
|
+
return AdapterRegistry(
|
|
247
|
+
[
|
|
248
|
+
OpenAPISourceAdapter(),
|
|
249
|
+
OPTIMADESourceAdapter(),
|
|
250
|
+
MCPSourceAdapter(),
|
|
251
|
+
]
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class URLSchemaLoader:
|
|
256
|
+
"""Resolve structured URL sources through a pluggable adapter registry."""
|
|
257
|
+
|
|
258
|
+
def __init__(
|
|
259
|
+
self,
|
|
260
|
+
registry: ToolRegistry,
|
|
261
|
+
executor: RegistryExecutor,
|
|
262
|
+
*,
|
|
263
|
+
http_client: httpx.AsyncClient | None = None,
|
|
264
|
+
adapters: AdapterRegistry | None = None,
|
|
265
|
+
) -> None:
|
|
266
|
+
self.registry = registry
|
|
267
|
+
self.executor = executor
|
|
268
|
+
self.http_client = http_client
|
|
269
|
+
self.adapters = adapters if adapters is not None else default_adapter_registry()
|
|
270
|
+
|
|
271
|
+
def register_adapter(self, adapter: SourceAdapter, *, replace: bool = False) -> None:
|
|
272
|
+
self.adapters.register(adapter, replace=replace)
|
|
273
|
+
|
|
274
|
+
async def load(
|
|
275
|
+
self,
|
|
276
|
+
url: str,
|
|
277
|
+
*,
|
|
278
|
+
kind: SourceKind = "auto",
|
|
279
|
+
name: str | None = None,
|
|
280
|
+
namespace: str | None = None,
|
|
281
|
+
replace: bool = False,
|
|
282
|
+
base_url: str | None = None,
|
|
283
|
+
schema_headers: dict[str, str] | None = None,
|
|
284
|
+
trusted_headers: dict[str, str] | None = None,
|
|
285
|
+
timeout: float = 20.0,
|
|
286
|
+
) -> ToolSpec:
|
|
287
|
+
_validate_url(url)
|
|
288
|
+
normalized_kind = kind.strip().lower()
|
|
289
|
+
context = AdapterContext(
|
|
290
|
+
url=url,
|
|
291
|
+
name=name,
|
|
292
|
+
namespace=namespace,
|
|
293
|
+
base_url=base_url,
|
|
294
|
+
schema_headers=schema_headers,
|
|
295
|
+
trusted_headers=trusted_headers,
|
|
296
|
+
timeout=timeout,
|
|
297
|
+
http_client=self.http_client,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
diagnostics: list[str] = []
|
|
301
|
+
if normalized_kind != "auto":
|
|
302
|
+
try:
|
|
303
|
+
adapter = self.adapters.get(normalized_kind)
|
|
304
|
+
except KeyError as exc:
|
|
305
|
+
supported = ", ".join(self.adapters.kinds())
|
|
306
|
+
raise SchemaSourceError(
|
|
307
|
+
f"unsupported source kind {kind!r}; registered kinds: {supported}"
|
|
308
|
+
) from exc
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
result = await adapter.load(context)
|
|
312
|
+
except SchemaSourceError as exc:
|
|
313
|
+
if normalized_kind == "openapi":
|
|
314
|
+
raise UnsupportedSchemaSourceError(
|
|
315
|
+
f"URL did not yield a supported OpenAPI source: {exc}"
|
|
316
|
+
) from exc
|
|
317
|
+
raise
|
|
318
|
+
except Exception as exc: # noqa: BLE001
|
|
319
|
+
raise SchemaSourceError(
|
|
320
|
+
f"{normalized_kind} adapter failed for {url!r}"
|
|
321
|
+
) from exc
|
|
322
|
+
if result is None:
|
|
323
|
+
raise UnsupportedSchemaSourceError(
|
|
324
|
+
f"URL did not yield a supported {normalized_kind} source"
|
|
325
|
+
)
|
|
326
|
+
return self._commit(result, replace=replace)
|
|
327
|
+
|
|
328
|
+
for adapter in self.adapters.ordered():
|
|
329
|
+
try:
|
|
330
|
+
result = await adapter.load(context)
|
|
331
|
+
except Exception as exc: # noqa: BLE001
|
|
332
|
+
diagnostics.append(f"{adapter.kind}: {exc}")
|
|
333
|
+
continue
|
|
334
|
+
if result is not None:
|
|
335
|
+
return self._commit(result, replace=replace)
|
|
336
|
+
|
|
337
|
+
detail = "; ".join(diagnostics) or "no registered adapter recognized the source"
|
|
338
|
+
raise UnsupportedSchemaSourceError(
|
|
339
|
+
"URL was not recognized by any registered structured-source adapter. "
|
|
340
|
+
"Human-readable documentation is intentionally not inferred in the safe path. "
|
|
341
|
+
+ detail
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def _commit(self, result: AdapterLoadResult, *, replace: bool) -> ToolSpec:
|
|
345
|
+
key = self.registry.register(result.tool, replace=replace)
|
|
346
|
+
if result.invoker is not None:
|
|
347
|
+
self.executor.bind(key, result.invoker)
|
|
348
|
+
return self.registry.get(key)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..models import ToolCall
|
|
9
|
+
from ..runtime import SchemaRouter
|
|
10
|
+
from ..validation import effective_input_schema
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _langchain_name(tool_key: str, endpoint_name: str) -> str:
|
|
14
|
+
raw = f"schemarouter__{tool_key}__{endpoint_name}"
|
|
15
|
+
return re.sub(r"[^A-Za-z0-9_-]+", "_", raw)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _sync_await(coroutine_factory):
|
|
19
|
+
try:
|
|
20
|
+
asyncio.get_running_loop()
|
|
21
|
+
except RuntimeError:
|
|
22
|
+
return asyncio.run(coroutine_factory())
|
|
23
|
+
raise RuntimeError(
|
|
24
|
+
"synchronous LangChain tool invocation cannot run inside an active event loop; "
|
|
25
|
+
"use ainvoke() instead"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def to_langchain_tool(
|
|
30
|
+
router: SchemaRouter,
|
|
31
|
+
tool_key: str,
|
|
32
|
+
endpoint_name: str,
|
|
33
|
+
*,
|
|
34
|
+
name: str | None = None,
|
|
35
|
+
description: str | None = None,
|
|
36
|
+
):
|
|
37
|
+
"""Expose one registered SchemaRouter endpoint as a LangChain StructuredTool."""
|
|
38
|
+
try:
|
|
39
|
+
from langchain_core.tools import StructuredTool
|
|
40
|
+
except ImportError as exc:
|
|
41
|
+
raise ImportError(
|
|
42
|
+
'LangChain integration requires: pip install "schemarouter[langchain]"'
|
|
43
|
+
) from exc
|
|
44
|
+
|
|
45
|
+
tool = router.registry.get(tool_key)
|
|
46
|
+
endpoint = tool.endpoint(endpoint_name)
|
|
47
|
+
fields = [field.name for field in endpoint.output_fields]
|
|
48
|
+
|
|
49
|
+
async def ainvoke_endpoint(**arguments: Any) -> Any:
|
|
50
|
+
call = ToolCall(
|
|
51
|
+
tool=tool_key,
|
|
52
|
+
endpoint=endpoint_name,
|
|
53
|
+
arguments=arguments,
|
|
54
|
+
fields=fields,
|
|
55
|
+
schema_fingerprint=endpoint.fingerprint,
|
|
56
|
+
)
|
|
57
|
+
result = await router.executor.execute_call(call)
|
|
58
|
+
return result.data
|
|
59
|
+
|
|
60
|
+
def invoke_endpoint(**arguments: Any) -> Any:
|
|
61
|
+
return _sync_await(lambda: ainvoke_endpoint(**arguments))
|
|
62
|
+
|
|
63
|
+
return StructuredTool(
|
|
64
|
+
name=name or _langchain_name(tool_key, endpoint_name),
|
|
65
|
+
description=(
|
|
66
|
+
description
|
|
67
|
+
or endpoint.description
|
|
68
|
+
or tool.description
|
|
69
|
+
or f"SchemaRouter endpoint {tool_key}.{endpoint_name}"
|
|
70
|
+
),
|
|
71
|
+
args_schema=effective_input_schema(endpoint),
|
|
72
|
+
func=invoke_endpoint,
|
|
73
|
+
coroutine=ainvoke_endpoint,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def to_langchain_tools(
|
|
78
|
+
router: SchemaRouter,
|
|
79
|
+
*,
|
|
80
|
+
tool_keys: Sequence[str] | None = None,
|
|
81
|
+
) -> list[Any]:
|
|
82
|
+
"""Expose all selected registered endpoints as LangChain StructuredTool objects."""
|
|
83
|
+
selected = set(tool_keys) if tool_keys is not None else None
|
|
84
|
+
tools = []
|
|
85
|
+
for tool in router.registry.tools():
|
|
86
|
+
if selected is not None and tool.key not in selected:
|
|
87
|
+
continue
|
|
88
|
+
for endpoint in tool.endpoints:
|
|
89
|
+
tools.append(
|
|
90
|
+
to_langchain_tool(
|
|
91
|
+
router,
|
|
92
|
+
tool.key,
|
|
93
|
+
endpoint.name,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return tools
|
schemarouter/models.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StrictModel(BaseModel):
|
|
11
|
+
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ParameterSpec(StrictModel):
|
|
15
|
+
name: str
|
|
16
|
+
wire_name: str | None = None
|
|
17
|
+
description: str = ""
|
|
18
|
+
required: bool = False
|
|
19
|
+
location: Literal["path", "query", "header", "body", "argument"] = "argument"
|
|
20
|
+
json_schema: dict[str, Any] = Field(default_factory=dict)
|
|
21
|
+
aliases: list[str] = Field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FieldSpec(StrictModel):
|
|
25
|
+
name: str
|
|
26
|
+
description: str = ""
|
|
27
|
+
json_schema: dict[str, Any] = Field(default_factory=dict)
|
|
28
|
+
aliases: list[str] = Field(default_factory=list)
|
|
29
|
+
unit: str | None = None
|
|
30
|
+
identifier: bool = False
|
|
31
|
+
source_type: str | None = None
|
|
32
|
+
license: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EndpointSpec(StrictModel):
|
|
36
|
+
name: str
|
|
37
|
+
description: str = ""
|
|
38
|
+
parameters: list[ParameterSpec] = Field(default_factory=list)
|
|
39
|
+
output_fields: list[FieldSpec] = Field(default_factory=list)
|
|
40
|
+
input_schema: dict[str, Any] = Field(default_factory=dict)
|
|
41
|
+
output_schema: dict[str, Any] = Field(default_factory=dict)
|
|
42
|
+
method: str | None = None
|
|
43
|
+
path: str | None = None
|
|
44
|
+
read_only: bool | None = None
|
|
45
|
+
destructive: bool | None = None
|
|
46
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
@model_validator(mode="after")
|
|
49
|
+
def validate_unique_names(self) -> EndpointSpec:
|
|
50
|
+
pnames = [p.name for p in self.parameters]
|
|
51
|
+
fnames = [f.name for f in self.output_fields]
|
|
52
|
+
if len(pnames) != len(set(pnames)):
|
|
53
|
+
raise ValueError(f"duplicate parameter name in endpoint {self.name!r}")
|
|
54
|
+
if len(fnames) != len(set(fnames)):
|
|
55
|
+
raise ValueError(f"duplicate output field name in endpoint {self.name!r}")
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def fingerprint(self) -> str:
|
|
60
|
+
payload = self.model_dump(mode="json", exclude={"metadata"})
|
|
61
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
62
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ToolSpec(StrictModel):
|
|
66
|
+
name: str
|
|
67
|
+
namespace: str | None = None
|
|
68
|
+
description: str = ""
|
|
69
|
+
endpoints: list[EndpointSpec]
|
|
70
|
+
source_type: str | None = None
|
|
71
|
+
license: str | None = None
|
|
72
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
@model_validator(mode="after")
|
|
75
|
+
def validate_endpoints(self) -> ToolSpec:
|
|
76
|
+
names = [e.name for e in self.endpoints]
|
|
77
|
+
if not names:
|
|
78
|
+
raise ValueError("tool must define at least one endpoint")
|
|
79
|
+
if len(names) != len(set(names)):
|
|
80
|
+
raise ValueError(f"duplicate endpoint name in tool {self.name!r}")
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def key(self) -> str:
|
|
85
|
+
return f"{self.namespace}.{self.name}" if self.namespace else self.name
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def fingerprint(self) -> str:
|
|
89
|
+
payload = self.model_dump(mode="json", exclude={"metadata"})
|
|
90
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
91
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
92
|
+
|
|
93
|
+
def endpoint(self, name: str) -> EndpointSpec:
|
|
94
|
+
for endpoint in self.endpoints:
|
|
95
|
+
if endpoint.name == name:
|
|
96
|
+
return endpoint
|
|
97
|
+
raise KeyError(name)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class EvidenceRequirements(StrictModel):
|
|
101
|
+
provenance: bool = False
|
|
102
|
+
license: bool = False
|
|
103
|
+
units: bool = False
|
|
104
|
+
source_type: str | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class QueryIntent(StrictModel):
|
|
108
|
+
concepts: list[str] = Field(default_factory=list)
|
|
109
|
+
preferred_tools: list[str] = Field(default_factory=list)
|
|
110
|
+
preferred_endpoints: list[str] = Field(default_factory=list)
|
|
111
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
112
|
+
evidence: EvidenceRequirements = Field(default_factory=EvidenceRequirements)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PlanRequest(StrictModel):
|
|
116
|
+
query: str
|
|
117
|
+
concepts: list[str] = Field(default_factory=list)
|
|
118
|
+
preferred_tools: list[str] = Field(default_factory=list)
|
|
119
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
120
|
+
evidence: EvidenceRequirements = Field(default_factory=EvidenceRequirements)
|
|
121
|
+
max_calls: int = Field(default=1, ge=1, le=32)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ToolCall(StrictModel):
|
|
125
|
+
tool: str
|
|
126
|
+
endpoint: str
|
|
127
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
128
|
+
fields: list[str] = Field(default_factory=list)
|
|
129
|
+
evidence: EvidenceRequirements = Field(default_factory=EvidenceRequirements)
|
|
130
|
+
schema_fingerprint: str
|
|
131
|
+
missing_required_arguments: list[str] = Field(default_factory=list)
|
|
132
|
+
score: float = 0.0
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def executable(self) -> bool:
|
|
136
|
+
return not self.missing_required_arguments
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ExecutionPlan(StrictModel):
|
|
140
|
+
query: str
|
|
141
|
+
registry_version: int
|
|
142
|
+
calls: list[ToolCall] = Field(default_factory=list)
|
|
143
|
+
warnings: list[str] = Field(default_factory=list)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def executable(self) -> bool:
|
|
147
|
+
return bool(self.calls) and all(call.executable for call in self.calls)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class ToolResult(StrictModel):
|
|
151
|
+
tool: str
|
|
152
|
+
endpoint: str
|
|
153
|
+
data: Any
|
|
154
|
+
projected_fields: list[str] = Field(default_factory=list)
|