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.
@@ -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,3 @@
1
+ from .langchain import to_langchain_tool, to_langchain_tools
2
+
3
+ __all__ = ["to_langchain_tool", "to_langchain_tools"]
@@ -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)