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,656 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import quote, urljoin, urlparse
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ..errors import SchemaSourceError
|
|
11
|
+
from ..models import EndpointSpec, FieldSpec, ParameterSpec, ToolCall, ToolSpec
|
|
12
|
+
from .base import AdapterContext, AdapterLoadResult
|
|
13
|
+
|
|
14
|
+
_MAX_DISCOVERY_BYTES = 2 * 1024 * 1024
|
|
15
|
+
_MAX_RESPONSE_BYTES = 16 * 1024 * 1024
|
|
16
|
+
_VERSION_SEGMENT = re.compile(r"^v\d+(?:\.\d+)?$")
|
|
17
|
+
_ENTRY_SEGMENT = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _slug(value: str) -> str:
|
|
21
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "_", value.strip()).strip("_.-").lower()
|
|
22
|
+
return slug or "optimade"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _safe_base_url(url: str) -> str:
|
|
26
|
+
parsed = urlparse(url)
|
|
27
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
28
|
+
raise SchemaSourceError("OPTIMADE URL must be an absolute http(s) URL")
|
|
29
|
+
if parsed.username or parsed.password:
|
|
30
|
+
raise SchemaSourceError("OPTIMADE URL must not contain credentials")
|
|
31
|
+
if parsed.query or parsed.fragment:
|
|
32
|
+
raise SchemaSourceError("OPTIMADE base URL must not contain query or fragment")
|
|
33
|
+
return url.rstrip("/")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _candidate_versioned_bases(url: str) -> tuple[str, ...]:
|
|
37
|
+
base = _safe_base_url(url)
|
|
38
|
+
leaf = urlparse(base).path.rstrip("/").rsplit("/", 1)[-1]
|
|
39
|
+
if _VERSION_SEGMENT.fullmatch(leaf):
|
|
40
|
+
return (base,)
|
|
41
|
+
return (f"{base}/v1", base)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _validate_entry_type(entry_type: str) -> str:
|
|
45
|
+
parts = entry_type.split("/")
|
|
46
|
+
if not parts or any(not part or not _ENTRY_SEGMENT.fullmatch(part) for part in parts):
|
|
47
|
+
raise SchemaSourceError(f"unsafe OPTIMADE entry type: {entry_type!r}")
|
|
48
|
+
return entry_type
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _bounded_get(
|
|
52
|
+
client: httpx.AsyncClient,
|
|
53
|
+
url: str,
|
|
54
|
+
*,
|
|
55
|
+
headers: dict[str, str] | None,
|
|
56
|
+
params: dict[str, Any] | None = None,
|
|
57
|
+
max_bytes: int,
|
|
58
|
+
max_redirects: int = 5,
|
|
59
|
+
) -> httpx.Response:
|
|
60
|
+
current = _safe_base_url(url)
|
|
61
|
+
initial = urlparse(current)
|
|
62
|
+
query_params = params
|
|
63
|
+
|
|
64
|
+
for _ in range(max_redirects + 1):
|
|
65
|
+
async with client.stream(
|
|
66
|
+
"GET",
|
|
67
|
+
current,
|
|
68
|
+
headers=headers,
|
|
69
|
+
params=query_params,
|
|
70
|
+
follow_redirects=False,
|
|
71
|
+
) as response:
|
|
72
|
+
if response.is_redirect:
|
|
73
|
+
location = response.headers.get("location")
|
|
74
|
+
if not location:
|
|
75
|
+
raise SchemaSourceError("OPTIMADE redirect is missing Location")
|
|
76
|
+
target = urljoin(current, location)
|
|
77
|
+
parsed = urlparse(target)
|
|
78
|
+
if (
|
|
79
|
+
parsed.scheme not in {"http", "https"}
|
|
80
|
+
or not parsed.netloc
|
|
81
|
+
or parsed.username
|
|
82
|
+
or parsed.password
|
|
83
|
+
):
|
|
84
|
+
raise SchemaSourceError("OPTIMADE redirect target is not a safe http(s) URL")
|
|
85
|
+
if (
|
|
86
|
+
parsed.scheme.casefold() != initial.scheme.casefold()
|
|
87
|
+
or parsed.hostname != initial.hostname
|
|
88
|
+
or (parsed.port or (443 if parsed.scheme == "https" else 80))
|
|
89
|
+
!= (initial.port or (443 if initial.scheme == "https" else 80))
|
|
90
|
+
):
|
|
91
|
+
raise SchemaSourceError(
|
|
92
|
+
"cross-origin OPTIMADE redirects are not allowed"
|
|
93
|
+
)
|
|
94
|
+
current = target
|
|
95
|
+
query_params = None
|
|
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_bytes:
|
|
106
|
+
raise SchemaSourceError(
|
|
107
|
+
f"OPTIMADE response exceeds {max_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_bytes:
|
|
115
|
+
raise SchemaSourceError(
|
|
116
|
+
f"OPTIMADE response exceeds {max_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("OPTIMADE URL exceeded the redirect limit")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _base_info_attributes(document: Any) -> dict[str, Any] | None:
|
|
131
|
+
if not isinstance(document, dict):
|
|
132
|
+
return None
|
|
133
|
+
data = document.get("data")
|
|
134
|
+
if not isinstance(data, dict) or data.get("type") != "info":
|
|
135
|
+
return None
|
|
136
|
+
attributes = data.get("attributes")
|
|
137
|
+
if not isinstance(attributes, dict):
|
|
138
|
+
return None
|
|
139
|
+
if not isinstance(attributes.get("api_version"), str):
|
|
140
|
+
return None
|
|
141
|
+
if not isinstance(attributes.get("available_endpoints"), list):
|
|
142
|
+
return None
|
|
143
|
+
return attributes
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _entry_info_payload(document: Any, entry_type: str) -> dict[str, Any] | None:
|
|
147
|
+
if not isinstance(document, dict):
|
|
148
|
+
return None
|
|
149
|
+
data = document.get("data")
|
|
150
|
+
if not isinstance(data, dict):
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
declared_type = data.get("type")
|
|
154
|
+
declared_id = data.get("id")
|
|
155
|
+
if declared_type is not None and declared_type != "info":
|
|
156
|
+
return None
|
|
157
|
+
if declared_id is not None and declared_id != entry_type:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
payload = dict(data)
|
|
161
|
+
attributes = data.get("attributes")
|
|
162
|
+
if isinstance(attributes, dict):
|
|
163
|
+
payload.update(attributes)
|
|
164
|
+
payload["_identity_inferred"] = declared_type is None or declared_id is None
|
|
165
|
+
return payload
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_OPTIMADE_TO_JSON_TYPE = {
|
|
169
|
+
"string": "string",
|
|
170
|
+
"integer": "integer",
|
|
171
|
+
"float": "number",
|
|
172
|
+
"boolean": "boolean",
|
|
173
|
+
"timestamp": "string",
|
|
174
|
+
"list": "array",
|
|
175
|
+
"dictionary": "object",
|
|
176
|
+
"number": "number",
|
|
177
|
+
"array": "array",
|
|
178
|
+
"object": "object",
|
|
179
|
+
"null": "null",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _normalize_optimade_schema(value: Any) -> Any:
|
|
184
|
+
if isinstance(value, list):
|
|
185
|
+
return [_normalize_optimade_schema(item) for item in value]
|
|
186
|
+
if not isinstance(value, dict):
|
|
187
|
+
return value
|
|
188
|
+
|
|
189
|
+
normalized = {
|
|
190
|
+
key: _normalize_optimade_schema(item)
|
|
191
|
+
for key, item in value.items()
|
|
192
|
+
if key not in {"title", "description"}
|
|
193
|
+
}
|
|
194
|
+
raw_type = normalized.get("type")
|
|
195
|
+
if isinstance(raw_type, str):
|
|
196
|
+
mapped = _OPTIMADE_TO_JSON_TYPE.get(raw_type)
|
|
197
|
+
if mapped is None:
|
|
198
|
+
normalized.pop("type", None)
|
|
199
|
+
else:
|
|
200
|
+
normalized["type"] = mapped
|
|
201
|
+
if raw_type == "timestamp":
|
|
202
|
+
normalized.setdefault("format", "date-time")
|
|
203
|
+
elif isinstance(raw_type, list):
|
|
204
|
+
mapped_types = [
|
|
205
|
+
_OPTIMADE_TO_JSON_TYPE[item]
|
|
206
|
+
for item in raw_type
|
|
207
|
+
if isinstance(item, str) and item in _OPTIMADE_TO_JSON_TYPE
|
|
208
|
+
]
|
|
209
|
+
if mapped_types:
|
|
210
|
+
normalized["type"] = list(dict.fromkeys(mapped_types))
|
|
211
|
+
if "timestamp" in raw_type:
|
|
212
|
+
normalized.setdefault("format", "date-time")
|
|
213
|
+
else:
|
|
214
|
+
normalized.pop("type", None)
|
|
215
|
+
|
|
216
|
+
return normalized
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _property_schema(spec: dict[str, Any]) -> dict[str, Any]:
|
|
220
|
+
return _normalize_optimade_schema(deepcopy(spec))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _field_from_property(name: str, spec: dict[str, Any]) -> FieldSpec:
|
|
224
|
+
description = str(spec.get("description") or spec.get("title") or "")
|
|
225
|
+
unit = spec.get("x-optimade-unit") or spec.get("unit")
|
|
226
|
+
return FieldSpec(
|
|
227
|
+
name=name,
|
|
228
|
+
description=description,
|
|
229
|
+
json_schema=_property_schema(spec),
|
|
230
|
+
aliases=[name.replace("_", " ")],
|
|
231
|
+
unit=str(unit) if unit not in {None, "inapplicable"} else None,
|
|
232
|
+
identifier=False,
|
|
233
|
+
source_type="optimade",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _output_schema(fields: list[FieldSpec], *, many: bool) -> dict[str, Any]:
|
|
238
|
+
properties = {field.name: deepcopy(field.json_schema) for field in fields}
|
|
239
|
+
item = {
|
|
240
|
+
"type": "object",
|
|
241
|
+
"properties": properties,
|
|
242
|
+
"required": ["id", "type"],
|
|
243
|
+
"additionalProperties": True,
|
|
244
|
+
}
|
|
245
|
+
if many:
|
|
246
|
+
return {"type": "array", "items": item}
|
|
247
|
+
return item
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _search_parameters() -> list[ParameterSpec]:
|
|
251
|
+
return [
|
|
252
|
+
ParameterSpec(
|
|
253
|
+
name="filter",
|
|
254
|
+
description="OPTIMADE filter expression.",
|
|
255
|
+
location="query",
|
|
256
|
+
json_schema={"type": "string"},
|
|
257
|
+
aliases=["where", "query filter"],
|
|
258
|
+
),
|
|
259
|
+
ParameterSpec(
|
|
260
|
+
name="page_limit",
|
|
261
|
+
description="Maximum number of entries to return.",
|
|
262
|
+
location="query",
|
|
263
|
+
json_schema={"type": "integer", "minimum": 1},
|
|
264
|
+
aliases=["limit", "top"],
|
|
265
|
+
),
|
|
266
|
+
ParameterSpec(
|
|
267
|
+
name="sort",
|
|
268
|
+
description="Comma-delimited OPTIMADE sort expression.",
|
|
269
|
+
location="query",
|
|
270
|
+
json_schema={"type": "string"},
|
|
271
|
+
),
|
|
272
|
+
ParameterSpec(
|
|
273
|
+
name="include",
|
|
274
|
+
description="Comma-delimited related resource types to include.",
|
|
275
|
+
location="query",
|
|
276
|
+
json_schema={"type": "string"},
|
|
277
|
+
),
|
|
278
|
+
ParameterSpec(
|
|
279
|
+
name="page_offset",
|
|
280
|
+
description="Offset-based pagination value.",
|
|
281
|
+
location="query",
|
|
282
|
+
json_schema={"type": "integer", "minimum": 0},
|
|
283
|
+
),
|
|
284
|
+
ParameterSpec(
|
|
285
|
+
name="page_number",
|
|
286
|
+
description="Page-number pagination value.",
|
|
287
|
+
location="query",
|
|
288
|
+
json_schema={"type": "integer", "minimum": 1},
|
|
289
|
+
),
|
|
290
|
+
ParameterSpec(
|
|
291
|
+
name="page_cursor",
|
|
292
|
+
description="Cursor-based pagination value.",
|
|
293
|
+
location="query",
|
|
294
|
+
json_schema={"type": "string"},
|
|
295
|
+
),
|
|
296
|
+
ParameterSpec(
|
|
297
|
+
name="email_address",
|
|
298
|
+
description="Optional contact email sent to the OPTIMADE provider.",
|
|
299
|
+
location="query",
|
|
300
|
+
json_schema={"type": "string"},
|
|
301
|
+
),
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _endpoint_token(entry_type: str) -> str:
|
|
306
|
+
return _slug(entry_type.replace("/", "__"))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _tool_from_discovery(
|
|
310
|
+
*,
|
|
311
|
+
name: str,
|
|
312
|
+
namespace: str | None,
|
|
313
|
+
versioned_base_url: str,
|
|
314
|
+
base_info: dict[str, Any],
|
|
315
|
+
entries: dict[str, dict[str, Any]],
|
|
316
|
+
skipped: list[str],
|
|
317
|
+
) -> ToolSpec:
|
|
318
|
+
endpoints: list[EndpointSpec] = []
|
|
319
|
+
inferred_identity: list[str] = []
|
|
320
|
+
for entry_type, info in entries.items():
|
|
321
|
+
if bool(info.get("_identity_inferred")):
|
|
322
|
+
inferred_identity.append(entry_type)
|
|
323
|
+
properties = info.get("properties")
|
|
324
|
+
if not isinstance(properties, dict):
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
output_by_format = info.get("output_fields_by_format")
|
|
328
|
+
json_fields: list[str]
|
|
329
|
+
if isinstance(output_by_format, dict) and isinstance(output_by_format.get("json"), list):
|
|
330
|
+
json_fields = [
|
|
331
|
+
str(field)
|
|
332
|
+
for field in output_by_format["json"]
|
|
333
|
+
if isinstance(field, str)
|
|
334
|
+
]
|
|
335
|
+
else:
|
|
336
|
+
json_fields = [str(field) for field in properties]
|
|
337
|
+
|
|
338
|
+
fields = [
|
|
339
|
+
FieldSpec(
|
|
340
|
+
name="id",
|
|
341
|
+
description="OPTIMADE entry identifier.",
|
|
342
|
+
json_schema={"type": "string"},
|
|
343
|
+
aliases=["identifier", "entry id"],
|
|
344
|
+
identifier=True,
|
|
345
|
+
source_type="optimade",
|
|
346
|
+
),
|
|
347
|
+
FieldSpec(
|
|
348
|
+
name="type",
|
|
349
|
+
description="OPTIMADE entry type.",
|
|
350
|
+
json_schema={"type": "string"},
|
|
351
|
+
aliases=["entry type"],
|
|
352
|
+
source_type="optimade",
|
|
353
|
+
),
|
|
354
|
+
]
|
|
355
|
+
seen = {"id", "type"}
|
|
356
|
+
for field_name in json_fields:
|
|
357
|
+
if field_name in seen:
|
|
358
|
+
continue
|
|
359
|
+
raw_spec = properties.get(field_name)
|
|
360
|
+
if not isinstance(raw_spec, dict):
|
|
361
|
+
continue
|
|
362
|
+
fields.append(_field_from_property(field_name, raw_spec))
|
|
363
|
+
seen.add(field_name)
|
|
364
|
+
|
|
365
|
+
token = _endpoint_token(entry_type)
|
|
366
|
+
description = str(info.get("description") or f"OPTIMADE {entry_type} entries")
|
|
367
|
+
safe_entry_type = _validate_entry_type(entry_type)
|
|
368
|
+
endpoints.extend(
|
|
369
|
+
[
|
|
370
|
+
EndpointSpec(
|
|
371
|
+
name=f"search_{token}",
|
|
372
|
+
description=f"Search {description}",
|
|
373
|
+
parameters=_search_parameters(),
|
|
374
|
+
output_fields=fields,
|
|
375
|
+
output_schema=_output_schema(fields, many=True),
|
|
376
|
+
method="GET",
|
|
377
|
+
path=f"/{safe_entry_type}",
|
|
378
|
+
read_only=True,
|
|
379
|
+
destructive=False,
|
|
380
|
+
metadata={
|
|
381
|
+
"entry_type": entry_type,
|
|
382
|
+
"mode": "search",
|
|
383
|
+
"field_projection": "response_fields",
|
|
384
|
+
},
|
|
385
|
+
),
|
|
386
|
+
EndpointSpec(
|
|
387
|
+
name=f"get_{token}",
|
|
388
|
+
description=f"Get one {description}",
|
|
389
|
+
parameters=[
|
|
390
|
+
ParameterSpec(
|
|
391
|
+
name="id",
|
|
392
|
+
description="OPTIMADE entry identifier.",
|
|
393
|
+
required=True,
|
|
394
|
+
location="path",
|
|
395
|
+
json_schema={"type": "string", "minLength": 1},
|
|
396
|
+
aliases=["identifier", "entry id"],
|
|
397
|
+
)
|
|
398
|
+
],
|
|
399
|
+
output_fields=fields,
|
|
400
|
+
output_schema=_output_schema(fields, many=False),
|
|
401
|
+
method="GET",
|
|
402
|
+
path=f"/{safe_entry_type}/{{id}}",
|
|
403
|
+
read_only=True,
|
|
404
|
+
destructive=False,
|
|
405
|
+
metadata={
|
|
406
|
+
"entry_type": entry_type,
|
|
407
|
+
"mode": "get",
|
|
408
|
+
"field_projection": "response_fields",
|
|
409
|
+
},
|
|
410
|
+
),
|
|
411
|
+
]
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if not endpoints:
|
|
415
|
+
detail = ", ".join(skipped) if skipped else "no entry types were discovered"
|
|
416
|
+
raise SchemaSourceError(
|
|
417
|
+
"OPTIMADE source exposed no usable entry schemas: " + detail
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
return ToolSpec(
|
|
421
|
+
name=name,
|
|
422
|
+
namespace=namespace,
|
|
423
|
+
description="OPTIMADE interoperable materials database",
|
|
424
|
+
endpoints=endpoints,
|
|
425
|
+
source_type="optimade",
|
|
426
|
+
metadata={
|
|
427
|
+
"adapter": "optimade",
|
|
428
|
+
"remote": True,
|
|
429
|
+
"api_version": base_info.get("api_version"),
|
|
430
|
+
"versioned_base_url": versioned_base_url,
|
|
431
|
+
"is_index": bool(base_info.get("is_index", False)),
|
|
432
|
+
"skipped_entry_types": skipped,
|
|
433
|
+
"inferred_entry_info_identity": inferred_identity,
|
|
434
|
+
},
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class OPTIMADESourceAdapter:
|
|
439
|
+
kind = "optimade"
|
|
440
|
+
priority = 90
|
|
441
|
+
|
|
442
|
+
async def load(self, context: AdapterContext) -> AdapterLoadResult | None:
|
|
443
|
+
candidates = _candidate_versioned_bases(context.base_url or context.url)
|
|
444
|
+
|
|
445
|
+
owns_client = context.http_client is None
|
|
446
|
+
client = context.http_client or httpx.AsyncClient(
|
|
447
|
+
timeout=context.timeout,
|
|
448
|
+
follow_redirects=False,
|
|
449
|
+
)
|
|
450
|
+
try:
|
|
451
|
+
versioned_base_url: str | None = None
|
|
452
|
+
base_info: dict[str, Any] | None = None
|
|
453
|
+
for candidate in candidates:
|
|
454
|
+
try:
|
|
455
|
+
response = await _bounded_get(
|
|
456
|
+
client,
|
|
457
|
+
f"{candidate}/info",
|
|
458
|
+
headers=context.schema_headers,
|
|
459
|
+
max_bytes=_MAX_DISCOVERY_BYTES,
|
|
460
|
+
)
|
|
461
|
+
document = response.json()
|
|
462
|
+
except SchemaSourceError:
|
|
463
|
+
raise
|
|
464
|
+
except Exception: # noqa: BLE001
|
|
465
|
+
continue
|
|
466
|
+
attributes = _base_info_attributes(document)
|
|
467
|
+
if attributes is not None:
|
|
468
|
+
versioned_base_url = candidate
|
|
469
|
+
base_info = attributes
|
|
470
|
+
break
|
|
471
|
+
|
|
472
|
+
if versioned_base_url is None or base_info is None:
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
if bool(base_info.get("is_index", False)):
|
|
476
|
+
raise SchemaSourceError(
|
|
477
|
+
"OPTIMADE index meta-databases are discovery catalogs, not executable "
|
|
478
|
+
"entry databases; provide a concrete provider database URL"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
entry_types_by_format = base_info.get("entry_types_by_format")
|
|
482
|
+
if (
|
|
483
|
+
isinstance(entry_types_by_format, dict)
|
|
484
|
+
and isinstance(entry_types_by_format.get("json"), list)
|
|
485
|
+
):
|
|
486
|
+
entry_types = [
|
|
487
|
+
str(value)
|
|
488
|
+
for value in entry_types_by_format["json"]
|
|
489
|
+
if isinstance(value, str)
|
|
490
|
+
]
|
|
491
|
+
else:
|
|
492
|
+
entry_types = [
|
|
493
|
+
str(value)
|
|
494
|
+
for value in base_info.get("available_endpoints", [])
|
|
495
|
+
if isinstance(value, str) and value not in {"info", "links"}
|
|
496
|
+
]
|
|
497
|
+
|
|
498
|
+
entry_types = list(dict.fromkeys(entry_types))[:32]
|
|
499
|
+
entries: dict[str, dict[str, Any]] = {}
|
|
500
|
+
skipped: list[str] = []
|
|
501
|
+
for entry_type in entry_types:
|
|
502
|
+
try:
|
|
503
|
+
safe_entry_type = _validate_entry_type(entry_type)
|
|
504
|
+
except SchemaSourceError as exc:
|
|
505
|
+
skipped.append(f"{entry_type}:{type(exc).__name__}")
|
|
506
|
+
continue
|
|
507
|
+
try:
|
|
508
|
+
response = await _bounded_get(
|
|
509
|
+
client,
|
|
510
|
+
f"{versioned_base_url}/info/{safe_entry_type}",
|
|
511
|
+
headers=context.schema_headers,
|
|
512
|
+
max_bytes=_MAX_DISCOVERY_BYTES,
|
|
513
|
+
)
|
|
514
|
+
payload = _entry_info_payload(response.json(), entry_type)
|
|
515
|
+
except SchemaSourceError:
|
|
516
|
+
raise
|
|
517
|
+
except Exception as exc: # noqa: BLE001
|
|
518
|
+
skipped.append(f"{entry_type}:{type(exc).__name__}")
|
|
519
|
+
continue
|
|
520
|
+
if payload is None or not isinstance(payload.get("properties"), dict):
|
|
521
|
+
skipped.append(f"{entry_type}:invalid_info")
|
|
522
|
+
continue
|
|
523
|
+
entries[entry_type] = payload
|
|
524
|
+
|
|
525
|
+
parsed = urlparse(versioned_base_url)
|
|
526
|
+
inferred_name = context.name or _slug(parsed.hostname or "optimade")
|
|
527
|
+
tool = _tool_from_discovery(
|
|
528
|
+
name=inferred_name,
|
|
529
|
+
namespace=context.namespace,
|
|
530
|
+
versioned_base_url=versioned_base_url,
|
|
531
|
+
base_info=base_info,
|
|
532
|
+
entries=entries,
|
|
533
|
+
skipped=skipped,
|
|
534
|
+
)
|
|
535
|
+
invoker = OPTIMADERemoteInvoker(
|
|
536
|
+
tool,
|
|
537
|
+
versioned_base_url,
|
|
538
|
+
trusted_headers=context.trusted_headers,
|
|
539
|
+
timeout=context.timeout,
|
|
540
|
+
http_client=context.http_client,
|
|
541
|
+
)
|
|
542
|
+
return AdapterLoadResult(tool=tool, invoker=invoker)
|
|
543
|
+
finally:
|
|
544
|
+
if owns_client:
|
|
545
|
+
await client.aclose()
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class OPTIMADERemoteInvoker:
|
|
549
|
+
"""Call-aware OPTIMADE invoker that maps planned fields to response_fields."""
|
|
550
|
+
|
|
551
|
+
projects_fields = True
|
|
552
|
+
|
|
553
|
+
def __init__(
|
|
554
|
+
self,
|
|
555
|
+
tool: ToolSpec,
|
|
556
|
+
versioned_base_url: str,
|
|
557
|
+
*,
|
|
558
|
+
trusted_headers: dict[str, str] | None = None,
|
|
559
|
+
timeout: float = 20.0,
|
|
560
|
+
http_client: httpx.AsyncClient | None = None,
|
|
561
|
+
) -> None:
|
|
562
|
+
self.tool = tool
|
|
563
|
+
self.base_url = _safe_base_url(versioned_base_url)
|
|
564
|
+
self.trusted_headers = dict(trusted_headers or {})
|
|
565
|
+
self.timeout = timeout
|
|
566
|
+
self.http_client = http_client
|
|
567
|
+
|
|
568
|
+
async def invoke_call(self, call: ToolCall) -> Any:
|
|
569
|
+
endpoint = self.tool.endpoint(call.endpoint)
|
|
570
|
+
entry_type = _validate_entry_type(str(endpoint.metadata["entry_type"]))
|
|
571
|
+
mode = str(endpoint.metadata["mode"])
|
|
572
|
+
|
|
573
|
+
arguments = dict(call.arguments)
|
|
574
|
+
query = {
|
|
575
|
+
key: value
|
|
576
|
+
for key, value in arguments.items()
|
|
577
|
+
if key != "id"
|
|
578
|
+
}
|
|
579
|
+
selected_fields = [
|
|
580
|
+
field
|
|
581
|
+
for field in call.fields
|
|
582
|
+
if field not in {"id", "type"}
|
|
583
|
+
]
|
|
584
|
+
if selected_fields:
|
|
585
|
+
query["response_fields"] = ",".join(selected_fields)
|
|
586
|
+
if mode == "search":
|
|
587
|
+
query.setdefault("page_limit", 20)
|
|
588
|
+
url = f"{self.base_url}/{entry_type}"
|
|
589
|
+
elif mode == "get":
|
|
590
|
+
entry_id = arguments.get("id")
|
|
591
|
+
if not isinstance(entry_id, str) or not entry_id:
|
|
592
|
+
raise RuntimeError("OPTIMADE get endpoint requires a non-empty id")
|
|
593
|
+
url = f"{self.base_url}/{entry_type}/{quote(entry_id, safe='')}"
|
|
594
|
+
else:
|
|
595
|
+
raise RuntimeError(f"unknown OPTIMADE endpoint mode: {mode!r}")
|
|
596
|
+
|
|
597
|
+
owns_client = self.http_client is None
|
|
598
|
+
client = self.http_client or httpx.AsyncClient(
|
|
599
|
+
timeout=self.timeout,
|
|
600
|
+
follow_redirects=False,
|
|
601
|
+
)
|
|
602
|
+
try:
|
|
603
|
+
response = await _bounded_get(
|
|
604
|
+
client,
|
|
605
|
+
url,
|
|
606
|
+
headers=self.trusted_headers,
|
|
607
|
+
params=query or None,
|
|
608
|
+
max_bytes=_MAX_RESPONSE_BYTES,
|
|
609
|
+
)
|
|
610
|
+
payload = response.json()
|
|
611
|
+
finally:
|
|
612
|
+
if owns_client:
|
|
613
|
+
await client.aclose()
|
|
614
|
+
|
|
615
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
616
|
+
if mode == "search":
|
|
617
|
+
if not isinstance(data, list):
|
|
618
|
+
raise RuntimeError("OPTIMADE listing response must contain a data list")
|
|
619
|
+
return [
|
|
620
|
+
self._flatten_entry(item, call.fields)
|
|
621
|
+
for item in data
|
|
622
|
+
]
|
|
623
|
+
|
|
624
|
+
if not isinstance(data, dict):
|
|
625
|
+
raise RuntimeError("OPTIMADE single-entry response must contain a data object")
|
|
626
|
+
return self._flatten_entry(data, call.fields)
|
|
627
|
+
|
|
628
|
+
@staticmethod
|
|
629
|
+
def _flatten_entry(item: Any, fields: list[str]) -> dict[str, Any]:
|
|
630
|
+
if not isinstance(item, dict):
|
|
631
|
+
raise RuntimeError("OPTIMADE entry must be an object")
|
|
632
|
+
attributes = item.get("attributes")
|
|
633
|
+
if not isinstance(attributes, dict):
|
|
634
|
+
attributes = {}
|
|
635
|
+
|
|
636
|
+
value: dict[str, Any] = {
|
|
637
|
+
"id": item.get("id"),
|
|
638
|
+
"type": item.get("type"),
|
|
639
|
+
**attributes,
|
|
640
|
+
}
|
|
641
|
+
requested = set(fields)
|
|
642
|
+
missing = sorted(
|
|
643
|
+
field
|
|
644
|
+
for field in requested
|
|
645
|
+
if field not in value
|
|
646
|
+
)
|
|
647
|
+
if missing:
|
|
648
|
+
raise RuntimeError(
|
|
649
|
+
"OPTIMADE provider omitted requested response fields: "
|
|
650
|
+
+ ", ".join(missing)
|
|
651
|
+
)
|
|
652
|
+
if not requested:
|
|
653
|
+
return value
|
|
654
|
+
|
|
655
|
+
keep = requested | {"id", "type"}
|
|
656
|
+
return {key: value[key] for key in value if key in keep}
|