toolschema 1.0.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.
- toolschema/__init__.py +29 -0
- toolschema/__main__.py +4 -0
- toolschema/_decorator.py +46 -0
- toolschema/_fields.py +65 -0
- toolschema/_init_scaffold.py +70 -0
- toolschema/_introspect.py +93 -0
- toolschema/_ir.py +61 -0
- toolschema/_schema_utils.py +11 -0
- toolschema/_standard.py +107 -0
- toolschema/_types.py +164 -0
- toolschema/_validate.py +289 -0
- toolschema/adapters/__init__.py +6 -0
- toolschema/adapters/_inline_refs.py +46 -0
- toolschema/adapters/anthropic.py +67 -0
- toolschema/adapters/gemini.py +58 -0
- toolschema/adapters/mcp.py +28 -0
- toolschema/adapters/openai.py +32 -0
- toolschema/cli.py +193 -0
- toolschema/integrations/__init__.py +21 -0
- toolschema/integrations/fastmcp.py +37 -0
- toolschema/integrations/langchain.py +26 -0
- toolschema/integrations/openai_agents.py +79 -0
- toolschema/integrations/pydantic_ai.py +44 -0
- toolschema/templates/__init__.py +0 -0
- toolschema/templates/mcp_server/README.md +47 -0
- toolschema/templates/mcp_server/claude_desktop_config.example.json +9 -0
- toolschema/templates/mcp_server/pyproject.toml +16 -0
- toolschema/templates/mcp_server/src/PACKAGE/__init__.py +1 -0
- toolschema/templates/mcp_server/src/PACKAGE/__main__.py +50 -0
- toolschema/templates/mcp_server/src/PACKAGE/tools.py +19 -0
- toolschema-1.0.0.dist-info/METADATA +428 -0
- toolschema-1.0.0.dist-info/RECORD +35 -0
- toolschema-1.0.0.dist-info/WHEEL +4 -0
- toolschema-1.0.0.dist-info/entry_points.txt +2 -0
- toolschema-1.0.0.dist-info/licenses/LICENSE +21 -0
toolschema/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""toolschema — function to JSON Schema for AI agent tools."""
|
|
2
|
+
|
|
3
|
+
from toolschema._decorator import tool
|
|
4
|
+
from toolschema._fields import Field
|
|
5
|
+
from toolschema._introspect import schema
|
|
6
|
+
from toolschema._ir import ToolDefinition
|
|
7
|
+
from toolschema._standard import JSONSchemaOptions, StandardSchemaHost
|
|
8
|
+
from toolschema._validate import (
|
|
9
|
+
ValidationFailure,
|
|
10
|
+
ValidationIssue,
|
|
11
|
+
ValidationResult,
|
|
12
|
+
ValidationSuccess,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = "1.0.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"tool",
|
|
19
|
+
"schema",
|
|
20
|
+
"Field",
|
|
21
|
+
"ToolDefinition",
|
|
22
|
+
"JSONSchemaOptions",
|
|
23
|
+
"StandardSchemaHost",
|
|
24
|
+
"ValidationFailure",
|
|
25
|
+
"ValidationIssue",
|
|
26
|
+
"ValidationResult",
|
|
27
|
+
"ValidationSuccess",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
toolschema/__main__.py
ADDED
toolschema/_decorator.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any, TypeVar, overload
|
|
6
|
+
|
|
7
|
+
from toolschema._introspect import ToolMeta
|
|
8
|
+
|
|
9
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@overload
|
|
13
|
+
def tool(fn: F) -> F: ...
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@overload
|
|
17
|
+
def tool(
|
|
18
|
+
*,
|
|
19
|
+
name: str | None = None,
|
|
20
|
+
description: str | None = None,
|
|
21
|
+
) -> Callable[[F], F]: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def tool(
|
|
25
|
+
fn: F | None = None,
|
|
26
|
+
*,
|
|
27
|
+
name: str | None = None,
|
|
28
|
+
description: str | None = None,
|
|
29
|
+
) -> F | Callable[[F], F]:
|
|
30
|
+
"""Mark a function as a tool and optionally override name or description."""
|
|
31
|
+
|
|
32
|
+
def decorator(func: F) -> F:
|
|
33
|
+
@functools.wraps(func)
|
|
34
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
35
|
+
return func(*args, **kwargs)
|
|
36
|
+
|
|
37
|
+
wrapper._toolschema = ToolMeta( # type: ignore[attr-defined]
|
|
38
|
+
name=name,
|
|
39
|
+
description=description,
|
|
40
|
+
)
|
|
41
|
+
wrapper.__wrapped__ = func # type: ignore[attr-defined]
|
|
42
|
+
return wrapper # type: ignore[return-value]
|
|
43
|
+
|
|
44
|
+
if fn is not None:
|
|
45
|
+
return decorator(fn)
|
|
46
|
+
return decorator
|
toolschema/_fields.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Field:
|
|
9
|
+
"""Metadata for Annotated type hints, mapped to JSON Schema constraints."""
|
|
10
|
+
|
|
11
|
+
description: str | None = None
|
|
12
|
+
min_length: int | None = None
|
|
13
|
+
max_length: int | None = None
|
|
14
|
+
ge: int | float | None = None
|
|
15
|
+
le: int | float | None = None
|
|
16
|
+
gt: int | float | None = None
|
|
17
|
+
lt: int | float | None = None
|
|
18
|
+
pattern: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def field_to_schema_extras(field: Field) -> dict[str, Any]:
|
|
22
|
+
"""Convert Field metadata to JSON Schema keyword fragments."""
|
|
23
|
+
extras: dict[str, Any] = {}
|
|
24
|
+
if field.description is not None:
|
|
25
|
+
extras["description"] = field.description
|
|
26
|
+
if field.min_length is not None:
|
|
27
|
+
extras["minLength"] = field.min_length
|
|
28
|
+
if field.max_length is not None:
|
|
29
|
+
extras["maxLength"] = field.max_length
|
|
30
|
+
if field.ge is not None:
|
|
31
|
+
extras["minimum"] = field.ge
|
|
32
|
+
if field.le is not None:
|
|
33
|
+
extras["maximum"] = field.le
|
|
34
|
+
if field.gt is not None:
|
|
35
|
+
extras["exclusiveMinimum"] = field.gt
|
|
36
|
+
if field.lt is not None:
|
|
37
|
+
extras["exclusiveMaximum"] = field.lt
|
|
38
|
+
if field.pattern is not None:
|
|
39
|
+
extras["pattern"] = field.pattern
|
|
40
|
+
return extras
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extract_annotated_metadata(metadata: tuple[Any, ...]) -> tuple[type[Any], dict[str, Any]]:
|
|
44
|
+
"""Extract base type and merged schema extras from Annotated metadata."""
|
|
45
|
+
if not metadata:
|
|
46
|
+
return object, {}
|
|
47
|
+
|
|
48
|
+
base_type = metadata[0] if isinstance(metadata[0], type) else metadata[0]
|
|
49
|
+
extras: dict[str, Any] = {}
|
|
50
|
+
|
|
51
|
+
for item in metadata[1:]:
|
|
52
|
+
if isinstance(item, Field):
|
|
53
|
+
extras.update(field_to_schema_extras(item))
|
|
54
|
+
elif isinstance(item, str):
|
|
55
|
+
if "description" not in extras:
|
|
56
|
+
extras["description"] = item
|
|
57
|
+
|
|
58
|
+
return base_type, extras
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def merge_field_into_schema(base_schema: dict[str, Any], extras: dict[str, Any]) -> dict[str, Any]:
|
|
62
|
+
"""Merge Field / Annotated metadata into a JSON Schema fragment."""
|
|
63
|
+
if not extras:
|
|
64
|
+
return base_schema
|
|
65
|
+
return {**base_schema, **extras}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import NamedTuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ScaffoldResult(NamedTuple):
|
|
11
|
+
root: Path
|
|
12
|
+
package_name: str
|
|
13
|
+
project_name: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def slugify_package_name(name: str) -> str:
|
|
17
|
+
slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
|
|
18
|
+
if not slug:
|
|
19
|
+
raise ValueError("Project name must contain at least one letter or digit")
|
|
20
|
+
if slug[0].isdigit():
|
|
21
|
+
slug = f"mcp_{slug}"
|
|
22
|
+
return slug
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _render(text: str, *, project_name: str, package_name: str, project_title: str) -> str:
|
|
26
|
+
return (
|
|
27
|
+
text.replace("{{project_name}}", project_name)
|
|
28
|
+
.replace("{{package_name}}", package_name)
|
|
29
|
+
.replace("{{project_title}}", project_title)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def scaffold_mcp_server(target_dir: Path, project_name: str) -> ScaffoldResult:
|
|
34
|
+
"""Scaffold a new MCP server project from the packaged template."""
|
|
35
|
+
package_name = slugify_package_name(project_name)
|
|
36
|
+
project_title = project_name.replace("-", " ").replace("_", " ").title()
|
|
37
|
+
root = target_dir.resolve() / project_name
|
|
38
|
+
|
|
39
|
+
if root.exists():
|
|
40
|
+
raise FileExistsError(f"Directory already exists: {root}")
|
|
41
|
+
|
|
42
|
+
template_root = resources.files("toolschema.templates") / "mcp_server"
|
|
43
|
+
_copy_template_tree(template_root, root)
|
|
44
|
+
|
|
45
|
+
for path in root.rglob("*"):
|
|
46
|
+
if not path.is_file():
|
|
47
|
+
continue
|
|
48
|
+
content = path.read_text(encoding="utf-8")
|
|
49
|
+
rendered = _render(
|
|
50
|
+
content,
|
|
51
|
+
project_name=project_name,
|
|
52
|
+
package_name=package_name,
|
|
53
|
+
project_title=project_title,
|
|
54
|
+
)
|
|
55
|
+
path.write_text(rendered, encoding="utf-8")
|
|
56
|
+
|
|
57
|
+
package_dir = root / "src" / "PACKAGE"
|
|
58
|
+
final_package_dir = root / "src" / package_name
|
|
59
|
+
package_dir.rename(final_package_dir)
|
|
60
|
+
|
|
61
|
+
return ScaffoldResult(root=root, package_name=package_name, project_name=project_name)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _copy_template_tree(source: resources.abc.Traversable, destination: Path) -> None:
|
|
65
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
for item in source.iterdir():
|
|
67
|
+
if item.is_dir():
|
|
68
|
+
_copy_template_tree(item, destination / item.name)
|
|
69
|
+
else:
|
|
70
|
+
shutil.copyfile(item, destination / item.name)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, get_type_hints
|
|
7
|
+
|
|
8
|
+
from toolschema._ir import ToolDefinition
|
|
9
|
+
from toolschema._types import JSON_SCHEMA_2020_12, type_to_schema
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ToolMeta:
|
|
14
|
+
"""Metadata attached by the @tool decorator."""
|
|
15
|
+
|
|
16
|
+
name: str | None = None
|
|
17
|
+
description: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _unwrap_tool(fn: Callable[..., Any]) -> tuple[Callable[..., Any], ToolMeta | None]:
|
|
21
|
+
meta = getattr(fn, "_toolschema", None)
|
|
22
|
+
if meta is not None:
|
|
23
|
+
wrapped = getattr(fn, "__wrapped__", fn)
|
|
24
|
+
return wrapped, meta
|
|
25
|
+
return fn, None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_docstring_description(doc: str | None) -> str:
|
|
29
|
+
if not doc:
|
|
30
|
+
return ""
|
|
31
|
+
paragraphs = doc.strip().split("\n\n")
|
|
32
|
+
return paragraphs[0].strip().replace("\n", " ")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_parameters_schema(fn: Callable[..., Any]) -> dict[str, Any]:
|
|
36
|
+
hints = get_type_hints(fn, include_extras=True)
|
|
37
|
+
sig = inspect.signature(fn)
|
|
38
|
+
|
|
39
|
+
properties: dict[str, Any] = {}
|
|
40
|
+
required: list[str] = []
|
|
41
|
+
|
|
42
|
+
for name, param in sig.parameters.items():
|
|
43
|
+
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
|
|
44
|
+
continue
|
|
45
|
+
if name in ("self", "cls"):
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
annotation = hints.get(name, Any)
|
|
49
|
+
prop_schema = type_to_schema(annotation)
|
|
50
|
+
|
|
51
|
+
if param.default is not inspect.Parameter.empty:
|
|
52
|
+
prop_schema = {**prop_schema, "default": param.default}
|
|
53
|
+
else:
|
|
54
|
+
required.append(name)
|
|
55
|
+
|
|
56
|
+
properties[name] = prop_schema
|
|
57
|
+
|
|
58
|
+
schema: dict[str, Any] = {
|
|
59
|
+
"$schema": JSON_SCHEMA_2020_12,
|
|
60
|
+
"type": "object",
|
|
61
|
+
"properties": properties,
|
|
62
|
+
"additionalProperties": False,
|
|
63
|
+
}
|
|
64
|
+
if required:
|
|
65
|
+
schema["required"] = required
|
|
66
|
+
return schema
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _build_output_schema(fn: Callable[..., Any]) -> dict[str, Any] | None:
|
|
70
|
+
hints = get_type_hints(fn, include_extras=True)
|
|
71
|
+
return_type = hints.get("return", inspect.Signature.empty)
|
|
72
|
+
if return_type is inspect.Signature.empty or return_type is None or return_type is type(None):
|
|
73
|
+
return None
|
|
74
|
+
return type_to_schema(return_type)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def schema(fn: Callable[..., Any]) -> ToolDefinition:
|
|
78
|
+
"""Build a ToolDefinition from a Python function's signature and annotations."""
|
|
79
|
+
target, meta = _unwrap_tool(fn)
|
|
80
|
+
|
|
81
|
+
name = (meta.name if meta and meta.name else None) or target.__name__
|
|
82
|
+
description = (
|
|
83
|
+
meta.description
|
|
84
|
+
if meta and meta.description is not None
|
|
85
|
+
else _parse_docstring_description(target.__doc__)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return ToolDefinition(
|
|
89
|
+
name=name,
|
|
90
|
+
description=description,
|
|
91
|
+
parameters=_build_parameters_schema(target),
|
|
92
|
+
output=_build_output_schema(target),
|
|
93
|
+
)
|
toolschema/_ir.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from toolschema._standard import StandardSchemaHost, build_standard_schema
|
|
7
|
+
from toolschema._validate import ValidationResult, validate_arguments
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ToolDefinition:
|
|
12
|
+
"""Intermediate representation of a tool derived from a Python function."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
description: str
|
|
16
|
+
parameters: dict[str, Any]
|
|
17
|
+
output: dict[str, Any] | None = None
|
|
18
|
+
|
|
19
|
+
def to_json_schema(self) -> dict[str, Any]:
|
|
20
|
+
"""Return canonical tool record with JSON Schema 2020-12 parameters."""
|
|
21
|
+
result: dict[str, Any] = {
|
|
22
|
+
"name": self.name,
|
|
23
|
+
"description": self.description,
|
|
24
|
+
"parameters": self.parameters,
|
|
25
|
+
}
|
|
26
|
+
if self.output is not None:
|
|
27
|
+
result["output"] = self.output
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
def validate(self, args: Any) -> ValidationResult:
|
|
31
|
+
"""Validate tool arguments against the canonical parameters schema."""
|
|
32
|
+
return validate_arguments(args, self.parameters)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def standard(self) -> StandardSchemaHost:
|
|
36
|
+
"""Standard Schema + Standard JSON Schema protocol host."""
|
|
37
|
+
return build_standard_schema(
|
|
38
|
+
parameters=self.parameters,
|
|
39
|
+
output=self.output,
|
|
40
|
+
validate=self.validate,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def to_openai(self, *, strict: bool = False) -> dict[str, Any]:
|
|
44
|
+
from toolschema.adapters.openai import to_openai
|
|
45
|
+
|
|
46
|
+
return to_openai(self, strict=strict)
|
|
47
|
+
|
|
48
|
+
def to_anthropic(self) -> dict[str, Any]:
|
|
49
|
+
from toolschema.adapters.anthropic import to_anthropic
|
|
50
|
+
|
|
51
|
+
return to_anthropic(self)
|
|
52
|
+
|
|
53
|
+
def to_mcp(self, *, inline_refs: bool = True) -> dict[str, Any]:
|
|
54
|
+
from toolschema.adapters.mcp import to_mcp
|
|
55
|
+
|
|
56
|
+
return to_mcp(self, inline_refs=inline_refs)
|
|
57
|
+
|
|
58
|
+
def to_gemini(self) -> dict[str, Any]:
|
|
59
|
+
from toolschema.adapters.gemini import to_gemini
|
|
60
|
+
|
|
61
|
+
return to_gemini(self)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def strip_canonical_meta(schema: dict[str, Any]) -> dict[str, Any]:
|
|
8
|
+
"""Remove JSON Schema dialect metadata not used by provider payloads."""
|
|
9
|
+
result = copy.deepcopy(schema)
|
|
10
|
+
result.pop("$schema", None)
|
|
11
|
+
return result
|
toolschema/_standard.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from toolschema._schema_utils import strip_canonical_meta
|
|
8
|
+
from toolschema._validate import ValidationResult
|
|
9
|
+
|
|
10
|
+
STANDARD_VERSION = 1
|
|
11
|
+
STANDARD_VENDOR = "toolschema"
|
|
12
|
+
|
|
13
|
+
JSONSchemaTarget = Literal["draft-2020-12", "draft-07", "openapi-3.0"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class JSONSchemaOptions:
|
|
18
|
+
target: JSONSchemaTarget = "draft-2020-12"
|
|
19
|
+
library_options: dict[str, Any] | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class StandardSchemaProps:
|
|
24
|
+
"""Standard Schema + Standard JSON Schema properties for ecosystem interop."""
|
|
25
|
+
|
|
26
|
+
version: int
|
|
27
|
+
vendor: str
|
|
28
|
+
validate: Callable[[Any], ValidationResult]
|
|
29
|
+
json_schema_input: Callable[[JSONSchemaOptions], dict[str, Any]]
|
|
30
|
+
json_schema_output: Callable[[JSONSchemaOptions], dict[str, Any]]
|
|
31
|
+
|
|
32
|
+
def to_mapping(self) -> dict[str, Any]:
|
|
33
|
+
return {
|
|
34
|
+
"version": self.version,
|
|
35
|
+
"vendor": self.vendor,
|
|
36
|
+
"validate": self.validate,
|
|
37
|
+
"jsonSchema": {
|
|
38
|
+
"input": self.json_schema_input,
|
|
39
|
+
"output": self.json_schema_output,
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class StandardSchemaHost(Mapping[str, Any]):
|
|
45
|
+
"""Mapping host exposing the ``~standard`` Standard Schema protocol key."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, props: StandardSchemaProps) -> None:
|
|
48
|
+
self._props = props
|
|
49
|
+
|
|
50
|
+
def __getitem__(self, key: str) -> Any:
|
|
51
|
+
if key == "~standard":
|
|
52
|
+
return self._props.to_mapping()
|
|
53
|
+
raise KeyError(key)
|
|
54
|
+
|
|
55
|
+
def __iter__(self):
|
|
56
|
+
yield "~standard"
|
|
57
|
+
|
|
58
|
+
def __len__(self) -> int:
|
|
59
|
+
return 1
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_standard_schema(
|
|
63
|
+
*,
|
|
64
|
+
parameters: dict[str, Any],
|
|
65
|
+
output: dict[str, Any] | None,
|
|
66
|
+
validate: Callable[[Any], ValidationResult],
|
|
67
|
+
) -> StandardSchemaHost:
|
|
68
|
+
def json_schema_input(
|
|
69
|
+
options: JSONSchemaOptions | dict[str, Any] | None = None,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
opts = _coerce_options(options)
|
|
72
|
+
_ensure_target(opts.target)
|
|
73
|
+
return strip_canonical_meta(parameters)
|
|
74
|
+
|
|
75
|
+
def json_schema_output(
|
|
76
|
+
options: JSONSchemaOptions | dict[str, Any] | None = None,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
opts = _coerce_options(options)
|
|
79
|
+
_ensure_target(opts.target)
|
|
80
|
+
if output is None:
|
|
81
|
+
raise ValueError("Tool has no output schema")
|
|
82
|
+
return strip_canonical_meta(output)
|
|
83
|
+
|
|
84
|
+
props = StandardSchemaProps(
|
|
85
|
+
version=STANDARD_VERSION,
|
|
86
|
+
vendor=STANDARD_VENDOR,
|
|
87
|
+
validate=validate,
|
|
88
|
+
json_schema_input=json_schema_input,
|
|
89
|
+
json_schema_output=json_schema_output,
|
|
90
|
+
)
|
|
91
|
+
return StandardSchemaHost(props)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _coerce_options(options: JSONSchemaOptions | dict[str, Any] | None) -> JSONSchemaOptions:
|
|
95
|
+
if options is None:
|
|
96
|
+
return JSONSchemaOptions()
|
|
97
|
+
if isinstance(options, JSONSchemaOptions):
|
|
98
|
+
return options
|
|
99
|
+
return JSONSchemaOptions(
|
|
100
|
+
target=options.get("target", "draft-2020-12"),
|
|
101
|
+
library_options=options.get("libraryOptions"),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _ensure_target(target: str) -> None:
|
|
106
|
+
if target not in {"draft-2020-12", "draft-07", "openapi-3.0"}:
|
|
107
|
+
raise ValueError(f"Unsupported JSON Schema target: {target!r}")
|
toolschema/_types.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import enum
|
|
5
|
+
import types
|
|
6
|
+
from typing import Annotated, Any, Literal, Union, get_args, get_origin, get_type_hints
|
|
7
|
+
|
|
8
|
+
from toolschema._fields import extract_annotated_metadata, merge_field_into_schema
|
|
9
|
+
|
|
10
|
+
JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _normalize_pydantic_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
14
|
+
"""Normalize a Pydantic model_json_schema() payload to a property schema."""
|
|
15
|
+
result = {k: v for k, v in schema.items() if k not in {"$defs", "$schema", "title"}}
|
|
16
|
+
properties = result.get("properties")
|
|
17
|
+
if isinstance(properties, dict):
|
|
18
|
+
result["properties"] = {
|
|
19
|
+
name: {k: v for k, v in prop.items() if k != "title"}
|
|
20
|
+
for name, prop in properties.items()
|
|
21
|
+
if isinstance(prop, dict)
|
|
22
|
+
}
|
|
23
|
+
if "properties" in result:
|
|
24
|
+
result.setdefault("type", "object")
|
|
25
|
+
result.setdefault("additionalProperties", False)
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _is_typeddict(tp: Any) -> bool:
|
|
30
|
+
try:
|
|
31
|
+
from typing_extensions import is_typeddict
|
|
32
|
+
|
|
33
|
+
return is_typeddict(tp)
|
|
34
|
+
except ImportError:
|
|
35
|
+
return isinstance(tp, type) and hasattr(tp, "__annotations__") and hasattr(tp, "__total__")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _typeddict_to_schema(tp: type[Any]) -> dict[str, Any]:
|
|
39
|
+
hints = get_type_hints(tp)
|
|
40
|
+
total = getattr(tp, "__total__", True)
|
|
41
|
+
properties = {name: type_to_schema(annotation) for name, annotation in hints.items()}
|
|
42
|
+
required = list(hints.keys()) if total else []
|
|
43
|
+
schema: dict[str, Any] = {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": properties,
|
|
46
|
+
"additionalProperties": False,
|
|
47
|
+
}
|
|
48
|
+
if required:
|
|
49
|
+
schema["required"] = required
|
|
50
|
+
return schema
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _dataclass_to_schema(tp: type[Any]) -> dict[str, Any]:
|
|
54
|
+
hints = get_type_hints(tp)
|
|
55
|
+
properties: dict[str, Any] = {}
|
|
56
|
+
required: list[str] = []
|
|
57
|
+
for field in dataclasses.fields(tp):
|
|
58
|
+
annotation = hints.get(field.name, Any)
|
|
59
|
+
properties[field.name] = type_to_schema(annotation)
|
|
60
|
+
if field.default is dataclasses.MISSING and field.default_factory is dataclasses.MISSING:
|
|
61
|
+
required.append(field.name)
|
|
62
|
+
elif field.default is not dataclasses.MISSING:
|
|
63
|
+
properties[field.name] = {
|
|
64
|
+
**properties[field.name],
|
|
65
|
+
"default": field.default,
|
|
66
|
+
}
|
|
67
|
+
schema: dict[str, Any] = {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"properties": properties,
|
|
70
|
+
"additionalProperties": False,
|
|
71
|
+
}
|
|
72
|
+
if required:
|
|
73
|
+
schema["required"] = required
|
|
74
|
+
return schema
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def type_to_schema(tp: Any) -> dict[str, Any]:
|
|
78
|
+
"""Convert a Python type annotation to a JSON Schema 2020-12 fragment."""
|
|
79
|
+
origin = get_origin(tp)
|
|
80
|
+
args = get_args(tp)
|
|
81
|
+
|
|
82
|
+
if origin is Annotated:
|
|
83
|
+
base_type, extras = extract_annotated_metadata(args)
|
|
84
|
+
schema = type_to_schema(base_type)
|
|
85
|
+
return merge_field_into_schema(schema, extras)
|
|
86
|
+
|
|
87
|
+
if origin is Union or isinstance(tp, types.UnionType):
|
|
88
|
+
non_none = [a for a in args if a is not type(None)]
|
|
89
|
+
if not non_none:
|
|
90
|
+
return {"type": "null"}
|
|
91
|
+
if len(non_none) == 1 and type(None) in args:
|
|
92
|
+
inner = type_to_schema(non_none[0])
|
|
93
|
+
return {"anyOf": [inner, {"type": "null"}]}
|
|
94
|
+
schemas = [type_to_schema(a) for a in args if a is not type(None)]
|
|
95
|
+
if type(None) in args:
|
|
96
|
+
schemas.append({"type": "null"})
|
|
97
|
+
if len(schemas) == 1:
|
|
98
|
+
return schemas[0]
|
|
99
|
+
return {"anyOf": schemas}
|
|
100
|
+
|
|
101
|
+
if origin is tuple:
|
|
102
|
+
if len(args) == 2 and args[1] is Ellipsis:
|
|
103
|
+
return {"type": "array", "items": type_to_schema(args[0])}
|
|
104
|
+
if args:
|
|
105
|
+
return {
|
|
106
|
+
"type": "array",
|
|
107
|
+
"prefixItems": [type_to_schema(arg) for arg in args],
|
|
108
|
+
"minItems": len(args),
|
|
109
|
+
"maxItems": len(args),
|
|
110
|
+
}
|
|
111
|
+
return {"type": "array"}
|
|
112
|
+
|
|
113
|
+
if origin is list:
|
|
114
|
+
item_type = args[0] if args else Any
|
|
115
|
+
return {"type": "array", "items": type_to_schema(item_type)}
|
|
116
|
+
|
|
117
|
+
if origin is dict:
|
|
118
|
+
if len(args) == 2 and args[0] is str:
|
|
119
|
+
return {
|
|
120
|
+
"type": "object",
|
|
121
|
+
"additionalProperties": type_to_schema(args[1]),
|
|
122
|
+
}
|
|
123
|
+
if not args:
|
|
124
|
+
return {"type": "object"}
|
|
125
|
+
raise TypeError(f"Unsupported dict type (only dict[str, T] supported): {tp!r}")
|
|
126
|
+
|
|
127
|
+
if origin is Literal:
|
|
128
|
+
values = list(args)
|
|
129
|
+
if all(isinstance(v, str) for v in values):
|
|
130
|
+
return {"enum": values}
|
|
131
|
+
if all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in values):
|
|
132
|
+
return {"enum": values}
|
|
133
|
+
if all(isinstance(v, bool) for v in values):
|
|
134
|
+
return {"enum": values}
|
|
135
|
+
return {"enum": values}
|
|
136
|
+
|
|
137
|
+
if isinstance(tp, type) and issubclass(tp, enum.Enum):
|
|
138
|
+
return {"enum": [member.value for member in tp]}
|
|
139
|
+
|
|
140
|
+
if isinstance(tp, type) and _is_typeddict(tp):
|
|
141
|
+
return _typeddict_to_schema(tp)
|
|
142
|
+
|
|
143
|
+
if isinstance(tp, type) and dataclasses.is_dataclass(tp):
|
|
144
|
+
return _dataclass_to_schema(tp)
|
|
145
|
+
|
|
146
|
+
if isinstance(tp, type) and hasattr(tp, "model_json_schema"):
|
|
147
|
+
return _normalize_pydantic_schema(tp.model_json_schema())
|
|
148
|
+
|
|
149
|
+
if tp is str:
|
|
150
|
+
return {"type": "string"}
|
|
151
|
+
if tp is int:
|
|
152
|
+
return {"type": "integer"}
|
|
153
|
+
if tp is float:
|
|
154
|
+
return {"type": "number"}
|
|
155
|
+
if tp is bool:
|
|
156
|
+
return {"type": "boolean"}
|
|
157
|
+
if tp is dict:
|
|
158
|
+
return {"type": "object"}
|
|
159
|
+
if tp is type(None):
|
|
160
|
+
return {"type": "null"}
|
|
161
|
+
if tp is Any:
|
|
162
|
+
return {}
|
|
163
|
+
|
|
164
|
+
raise TypeError(f"Unsupported type annotation: {tp!r}")
|