opentool-python 0.1.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.
- opentool_python/__init__.py +62 -0
- opentool_python/client/__init__.py +18 -0
- opentool_python/client/exceptions.py +48 -0
- opentool_python/client/opentool_client.py +238 -0
- opentool_python/dto.py +124 -0
- opentool_python/json_loader.py +23 -0
- opentool_python/llm/__init__.py +3 -0
- opentool_python/llm/model.py +35 -0
- opentool_python/model/__init__.py +23 -0
- opentool_python/model/data_type.py +8 -0
- opentool_python/model/function_model.py +66 -0
- opentool_python/model/info.py +25 -0
- opentool_python/model/opentool.py +59 -0
- opentool_python/model/parameter.py +36 -0
- opentool_python/model/return_model.py +27 -0
- opentool_python/model/schema.py +251 -0
- opentool_python/model/server.py +20 -0
- opentool_python/opentool_client.py +24 -0
- opentool_python/opentool_schema.py +27 -0
- opentool_python/opentool_server.py +17 -0
- opentool_python/server/__init__.py +5 -0
- opentool_python/server/cli_arguments.py +41 -0
- opentool_python/server/cli_tool.py +10 -0
- opentool_python/server/exceptions.py +31 -0
- opentool_python/server/opentool_server.py +310 -0
- opentool_python/tool/__init__.py +18 -0
- opentool_python/tool/exceptions.py +61 -0
- opentool_python/tool/model.py +24 -0
- opentool_python/tool/tool.py +30 -0
- opentool_python-0.1.0.dist-info/METADATA +180 -0
- opentool_python-0.1.0.dist-info/RECORD +34 -0
- opentool_python-0.1.0.dist-info/WHEEL +5 -0
- opentool_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- opentool_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from ..dto import (
|
|
13
|
+
CLI_ARGUMENT_APIKEYS,
|
|
14
|
+
CLI_ARGUMENT_HOST,
|
|
15
|
+
CLI_ARGUMENT_PORT,
|
|
16
|
+
CLI_ARGUMENT_TAG,
|
|
17
|
+
DEFAULT_PREFIX,
|
|
18
|
+
DEFAULT_TOOL_HOST,
|
|
19
|
+
DEFAULT_TOOL_PORT,
|
|
20
|
+
DEFAULT_TOOL_TAG,
|
|
21
|
+
JSONRPC_VERSION,
|
|
22
|
+
LOAD_METADATA_NOT_FOUND_CODE,
|
|
23
|
+
LOAD_METADATA_NOT_FOUND_MESSAGE,
|
|
24
|
+
JsonRPCHttpRequestBody,
|
|
25
|
+
JsonRPCHttpResponseBody,
|
|
26
|
+
JsonRPCHttpResponseBodyError,
|
|
27
|
+
StatusInfo,
|
|
28
|
+
StatusType,
|
|
29
|
+
Version,
|
|
30
|
+
)
|
|
31
|
+
from ..tool.model import EventType
|
|
32
|
+
from .cli_arguments import CliArguments
|
|
33
|
+
from .cli_tool import CliTool
|
|
34
|
+
from .exceptions import InvalidJsonPayloadException, JsonParseException, ServerException
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Server(ABC):
|
|
38
|
+
def __init__(self, cli_tool: CliTool) -> None:
|
|
39
|
+
self.cli_tool = cli_tool
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def start(self) -> None:
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def stop(self) -> None:
|
|
47
|
+
raise NotImplementedError
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OpenToolServer(Server):
|
|
51
|
+
def __init__(self, cli_tool: CliTool, cli_arguments: CliArguments | None = None) -> None:
|
|
52
|
+
super().__init__(cli_tool=cli_tool)
|
|
53
|
+
self.cli_arguments = cli_arguments
|
|
54
|
+
self.version = DEFAULT_TOOL_TAG
|
|
55
|
+
self.host = DEFAULT_TOOL_HOST
|
|
56
|
+
self.port = DEFAULT_TOOL_PORT
|
|
57
|
+
self.prefix = DEFAULT_PREFIX
|
|
58
|
+
self.api_keys: list[str] = []
|
|
59
|
+
self._server: ThreadingHTTPServer | None = None
|
|
60
|
+
|
|
61
|
+
def init_cli(self) -> None:
|
|
62
|
+
cli_args = self.cli_arguments.parse() if self.cli_arguments else {}
|
|
63
|
+
cli_args = self.cli_tool.init_args(cli_args) or cli_args
|
|
64
|
+
|
|
65
|
+
self.version = str(cli_args.get(CLI_ARGUMENT_TAG, DEFAULT_TOOL_TAG))
|
|
66
|
+
self.host = str(cli_args.get(CLI_ARGUMENT_HOST, DEFAULT_TOOL_HOST))
|
|
67
|
+
|
|
68
|
+
raw_port = cli_args.get(CLI_ARGUMENT_PORT)
|
|
69
|
+
if raw_port is not None:
|
|
70
|
+
self.port = int(raw_port)
|
|
71
|
+
|
|
72
|
+
api_keys = cli_args.get(CLI_ARGUMENT_APIKEYS)
|
|
73
|
+
self.api_keys = list(api_keys) if isinstance(api_keys, list) else []
|
|
74
|
+
|
|
75
|
+
def start(self) -> None:
|
|
76
|
+
self.init_cli()
|
|
77
|
+
self.cli_tool.init()
|
|
78
|
+
|
|
79
|
+
server = self
|
|
80
|
+
|
|
81
|
+
class Handler(BaseHTTPRequestHandler):
|
|
82
|
+
protocol_version = "HTTP/1.1"
|
|
83
|
+
|
|
84
|
+
def log_message(self, fmt: str, *args: Any) -> None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def do_GET(self) -> None: # noqa: N802
|
|
88
|
+
if not self._authorize():
|
|
89
|
+
return
|
|
90
|
+
path = self._path_without_query()
|
|
91
|
+
if path == f"{server.prefix}/version":
|
|
92
|
+
self._respond_json(HTTPStatus.OK, Version(version=server.version).to_json())
|
|
93
|
+
return
|
|
94
|
+
if path == f"{server.prefix}/load":
|
|
95
|
+
try:
|
|
96
|
+
open_tool = server.cli_tool.load()
|
|
97
|
+
if open_tool is None:
|
|
98
|
+
self._respond_json(
|
|
99
|
+
HTTPStatus.NOT_FOUND,
|
|
100
|
+
{"code": LOAD_METADATA_NOT_FOUND_CODE, "message": LOAD_METADATA_NOT_FOUND_MESSAGE},
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
self._respond_json(HTTPStatus.OK, open_tool.to_json())
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
self._respond_json(
|
|
106
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
107
|
+
{"error": str(exc)},
|
|
108
|
+
)
|
|
109
|
+
return
|
|
110
|
+
self._respond_json(HTTPStatus.NOT_FOUND, {"error": "Not found"})
|
|
111
|
+
|
|
112
|
+
def do_POST(self) -> None: # noqa: N802
|
|
113
|
+
if not self._authorize():
|
|
114
|
+
return
|
|
115
|
+
path = self._path_without_query()
|
|
116
|
+
if path == f"{server.prefix}/call":
|
|
117
|
+
self._handle_call()
|
|
118
|
+
return
|
|
119
|
+
if path == f"{server.prefix}/streamCall":
|
|
120
|
+
self._handle_stream_call()
|
|
121
|
+
return
|
|
122
|
+
if path == f"{server.prefix}/stop":
|
|
123
|
+
self._handle_stop()
|
|
124
|
+
return
|
|
125
|
+
self._respond_json(HTTPStatus.NOT_FOUND, {"error": "Not found"})
|
|
126
|
+
|
|
127
|
+
def _handle_call(self) -> None:
|
|
128
|
+
payload_result = self._read_json_payload()
|
|
129
|
+
if isinstance(payload_result, tuple):
|
|
130
|
+
self._respond_json(payload_result[0], payload_result[1])
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
payload = payload_result
|
|
134
|
+
try:
|
|
135
|
+
body = JsonRPCHttpRequestBody.from_json(payload)
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
self._respond_json(
|
|
138
|
+
HTTPStatus.BAD_REQUEST,
|
|
139
|
+
_json_parse_error_response(
|
|
140
|
+
JsonParseException(message=str(exc)),
|
|
141
|
+
id=str(payload.get("id", "")),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
result = server.cli_tool.call(body.method, body.params)
|
|
148
|
+
response_body = JsonRPCHttpResponseBody(result=result, id=body.id)
|
|
149
|
+
self._respond_json(HTTPStatus.OK, response_body.to_json())
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
response_body = JsonRPCHttpResponseBody(
|
|
152
|
+
result={},
|
|
153
|
+
id=body.id,
|
|
154
|
+
error=JsonRPCHttpResponseBodyError(code=500, message=str(exc)),
|
|
155
|
+
)
|
|
156
|
+
self._respond_json(HTTPStatus.OK, response_body.to_json())
|
|
157
|
+
|
|
158
|
+
def _handle_stream_call(self) -> None:
|
|
159
|
+
payload_result = self._read_json_payload()
|
|
160
|
+
if isinstance(payload_result, tuple):
|
|
161
|
+
self._respond_json(payload_result[0], payload_result[1])
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
payload = payload_result
|
|
165
|
+
try:
|
|
166
|
+
body = JsonRPCHttpRequestBody.from_json(payload)
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
self._respond_json(
|
|
169
|
+
HTTPStatus.BAD_REQUEST,
|
|
170
|
+
_json_parse_error_response(JsonParseException(message=str(exc))),
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
self.send_response(HTTPStatus.OK)
|
|
175
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
176
|
+
self.send_header("Cache-Control", "no-cache, no-store")
|
|
177
|
+
self.send_header("Connection", "close")
|
|
178
|
+
self.end_headers()
|
|
179
|
+
|
|
180
|
+
self._push_data(EventType.START, json.dumps({EventType.START: body.method}))
|
|
181
|
+
|
|
182
|
+
failed = False
|
|
183
|
+
try:
|
|
184
|
+
def send_event(event: str, data: dict[str, Any]) -> None:
|
|
185
|
+
nonlocal failed
|
|
186
|
+
if failed:
|
|
187
|
+
return
|
|
188
|
+
if event == EventType.DATA:
|
|
189
|
+
response_body = JsonRPCHttpResponseBody(result=data, id=body.id)
|
|
190
|
+
self._push_data(event, json.dumps(response_body.to_json()))
|
|
191
|
+
elif event == EventType.ERROR:
|
|
192
|
+
error = JsonRPCHttpResponseBodyError(
|
|
193
|
+
code=int(data.get("code", 500)),
|
|
194
|
+
message=str(data.get("message", data)),
|
|
195
|
+
)
|
|
196
|
+
response_body = JsonRPCHttpResponseBody(result={}, id=body.id, error=error)
|
|
197
|
+
self._push_data(event, json.dumps(response_body.to_json()))
|
|
198
|
+
failed = True
|
|
199
|
+
|
|
200
|
+
server.cli_tool.stream_call(body.method, body.params, send_event)
|
|
201
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
202
|
+
return
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
if not failed:
|
|
205
|
+
self._push_data(
|
|
206
|
+
EventType.ERROR,
|
|
207
|
+
json.dumps({"code": 500, "message": str(exc), "id": body.id}),
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
if not failed:
|
|
211
|
+
self._push_data(EventType.DONE, json.dumps({EventType.DONE: body.method}))
|
|
212
|
+
|
|
213
|
+
def _handle_stop(self) -> None:
|
|
214
|
+
try:
|
|
215
|
+
server.cli_tool.dispose()
|
|
216
|
+
self._respond_json(HTTPStatus.OK, StatusInfo(status=StatusType.STOPPED).to_json())
|
|
217
|
+
|
|
218
|
+
def shutdown_async() -> None:
|
|
219
|
+
server.stop()
|
|
220
|
+
|
|
221
|
+
threading.Thread(target=shutdown_async, daemon=True).start()
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
self._respond_json(
|
|
224
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
225
|
+
{"error": str(exc)},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def _read_json_payload(self) -> dict[str, Any] | tuple[int, dict[str, Any]]:
|
|
229
|
+
content_type = self.headers.get("Content-Type", "")
|
|
230
|
+
if "application/json" not in content_type:
|
|
231
|
+
return HTTPStatus.BAD_REQUEST, {"error": "Content-Type must be application/json"}
|
|
232
|
+
|
|
233
|
+
content_length = int(self.headers.get("Content-Length", "0"))
|
|
234
|
+
body = self.rfile.read(content_length).decode("utf-8") if content_length > 0 else ""
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
payload = json.loads(body)
|
|
238
|
+
except json.JSONDecodeError as exc:
|
|
239
|
+
return (
|
|
240
|
+
HTTPStatus.BAD_REQUEST,
|
|
241
|
+
_json_parse_error_response(JsonParseException(message=str(exc))),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if not isinstance(payload, dict):
|
|
245
|
+
return (
|
|
246
|
+
HTTPStatus.BAD_REQUEST,
|
|
247
|
+
_json_parse_error_response(InvalidJsonPayloadException()),
|
|
248
|
+
)
|
|
249
|
+
return payload
|
|
250
|
+
|
|
251
|
+
def _path_without_query(self) -> str:
|
|
252
|
+
return urlparse(self.path).path
|
|
253
|
+
|
|
254
|
+
def _authorize(self) -> bool:
|
|
255
|
+
if not server.api_keys:
|
|
256
|
+
return True
|
|
257
|
+
authorization = self.headers.get("Authorization")
|
|
258
|
+
token_prefix = "Bearer "
|
|
259
|
+
if authorization is None or not authorization.startswith(token_prefix):
|
|
260
|
+
self._respond_text(HTTPStatus.UNAUTHORIZED, "Missing or malformed authorization header")
|
|
261
|
+
return False
|
|
262
|
+
token = authorization[len(token_prefix) :]
|
|
263
|
+
if token not in server.api_keys:
|
|
264
|
+
self._respond_text(HTTPStatus.UNAUTHORIZED, "Invalid authorization token")
|
|
265
|
+
return False
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
def _respond_json(self, status: int, body: dict[str, Any]) -> None:
|
|
269
|
+
payload = json.dumps(body).encode("utf-8")
|
|
270
|
+
self.send_response(status)
|
|
271
|
+
self.send_header("Content-Type", "application/json")
|
|
272
|
+
self.send_header("Cache-Control", "no-cache")
|
|
273
|
+
self.send_header("Connection", "keep-alive")
|
|
274
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
275
|
+
self.end_headers()
|
|
276
|
+
self.wfile.write(payload)
|
|
277
|
+
|
|
278
|
+
def _respond_text(self, status: int, body: str) -> None:
|
|
279
|
+
payload = body.encode("utf-8")
|
|
280
|
+
self.send_response(status)
|
|
281
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
282
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
283
|
+
self.end_headers()
|
|
284
|
+
self.wfile.write(payload)
|
|
285
|
+
|
|
286
|
+
def _push_data(self, event_type: str, data_string: str) -> None:
|
|
287
|
+
payload = f"event:{event_type}\\ndata:{data_string}\\n\\n".encode("utf-8")
|
|
288
|
+
self.wfile.write(payload)
|
|
289
|
+
self.wfile.flush()
|
|
290
|
+
|
|
291
|
+
self._server = ThreadingHTTPServer((self.host, self.port), Handler)
|
|
292
|
+
print(f"Start Server: http://{self.host}:{self.port}{self.prefix}")
|
|
293
|
+
self._server.serve_forever(poll_interval=0.1)
|
|
294
|
+
|
|
295
|
+
def stop(self) -> None:
|
|
296
|
+
server = self._server
|
|
297
|
+
if server is None:
|
|
298
|
+
return
|
|
299
|
+
server.shutdown()
|
|
300
|
+
server.server_close()
|
|
301
|
+
self._server = None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _json_parse_error_response(exception: ServerException, id: str | None = None) -> dict[str, Any]:
|
|
305
|
+
return {
|
|
306
|
+
"jsonrpc": JSONRPC_VERSION,
|
|
307
|
+
"result": {},
|
|
308
|
+
"error": exception.to_json(),
|
|
309
|
+
"id": id,
|
|
310
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .exceptions import (
|
|
2
|
+
FunctionNotSupportedException,
|
|
3
|
+
InvalidArgumentsException,
|
|
4
|
+
JsonParserException,
|
|
5
|
+
ToolBreakException,
|
|
6
|
+
)
|
|
7
|
+
from .model import Event, EventType
|
|
8
|
+
from .tool import Tool
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Tool",
|
|
12
|
+
"Event",
|
|
13
|
+
"EventType",
|
|
14
|
+
"FunctionNotSupportedException",
|
|
15
|
+
"InvalidArgumentsException",
|
|
16
|
+
"ToolBreakException",
|
|
17
|
+
"JsonParserException",
|
|
18
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class FunctionNotSupportedException(Exception):
|
|
10
|
+
function_name: str
|
|
11
|
+
code: int = 405
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def message(self) -> str:
|
|
15
|
+
return f"Function Not Supported: {self.function_name}"
|
|
16
|
+
|
|
17
|
+
def to_json(self) -> dict[str, Any]:
|
|
18
|
+
return {"code": self.code, "message": self.message}
|
|
19
|
+
|
|
20
|
+
def __str__(self) -> str:
|
|
21
|
+
return json.dumps(self.to_json(), ensure_ascii=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class InvalidArgumentsException(Exception):
|
|
26
|
+
arguments: dict[str, Any] | None
|
|
27
|
+
code: int = 400
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def message(self) -> str:
|
|
31
|
+
return f"Invalid Arguments: {json.dumps(self.arguments, ensure_ascii=False)}"
|
|
32
|
+
|
|
33
|
+
def to_json(self) -> dict[str, Any]:
|
|
34
|
+
return {"code": self.code, "message": self.message}
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
return json.dumps(self.to_json(), ensure_ascii=False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ToolBreakException(Exception):
|
|
42
|
+
message: str | None
|
|
43
|
+
code: int = 500
|
|
44
|
+
|
|
45
|
+
def to_json(self) -> dict[str, Any]:
|
|
46
|
+
return {"code": self.code, "message": self.message}
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
return json.dumps(self.to_json(), ensure_ascii=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class JsonParserException(Exception):
|
|
54
|
+
code: int = 404
|
|
55
|
+
message: str = "Json Parser NOT implement"
|
|
56
|
+
|
|
57
|
+
def to_json(self) -> dict[str, Any]:
|
|
58
|
+
return {"code": self.code, "message": self.message}
|
|
59
|
+
|
|
60
|
+
def __str__(self) -> str:
|
|
61
|
+
return json.dumps(self.to_json(), ensure_ascii=False)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EventType:
|
|
8
|
+
START = "start"
|
|
9
|
+
DATA = "data"
|
|
10
|
+
ERROR = "error"
|
|
11
|
+
DONE = "done"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Event:
|
|
16
|
+
name: str
|
|
17
|
+
data: dict[str, Any]
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def from_json(cls, payload: dict[str, Any]) -> "Event":
|
|
21
|
+
return cls(name=str(payload["name"]), data=payload.get("data") or {})
|
|
22
|
+
|
|
23
|
+
def to_json(self) -> dict[str, Any]:
|
|
24
|
+
return {"name": self.name, "data": self.data}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..model.opentool import OpenTool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Tool(ABC):
|
|
11
|
+
def init(self) -> None:
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def call(self, name: str, arguments: dict[str, Any] | None) -> dict[str, Any]:
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
|
|
18
|
+
def stream_call(
|
|
19
|
+
self,
|
|
20
|
+
name: str,
|
|
21
|
+
arguments: dict[str, Any] | None,
|
|
22
|
+
on_event: Callable[[str, dict[str, Any]], None],
|
|
23
|
+
) -> None:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
def load(self) -> OpenTool | None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
def dispose(self) -> None:
|
|
30
|
+
return None
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opentool-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for OpenTool-compatible clients and servers
|
|
5
|
+
Author: OpenTool Hub
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/opentool-hub/opentool-python
|
|
8
|
+
Project-URL: Issues, https://github.com/opentool-hub/opentool-python/issues
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: jsonschema[format]<5,>=4.22
|
|
15
|
+
Requires-Dist: referencing<1,>=0.30
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# OpenTool SDK for Python
|
|
19
|
+
|
|
20
|
+
English | [中文](README-zh_CN.md)
|
|
21
|
+
|
|
22
|
+
OpenTool SDK for Python provides the core building blocks to host or consume OpenTool-compatible tools in Python: an HTTP/JSON-RPC server runtime, an HTTP client with unary + SSE streaming calls, and a JSON schema loader. This project is designed to align with [opentool-dart](https://github.com/opentool-hub/opentool-dart) so multi-language SDK behavior stays consistent.
|
|
23
|
+
|
|
24
|
+
## Library Entrypoints
|
|
25
|
+
|
|
26
|
+
- `opentool_python.opentool_server` - server runtime, CLI argument helper, and Tool base class.
|
|
27
|
+
- `opentool_python.opentool_client` - HTTP/JSON-RPC client plus DTO/LLM models.
|
|
28
|
+
- `opentool_python.opentool_schema` - JSON specification models and loader.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- `OpenToolServer` mounts `/opentool` JSON-RPC endpoints backed by your `Tool` implementation, with optional Bearer API-key auth.
|
|
33
|
+
- `OpenToolClient` supports version lookup, unary `call`, SSE `stream_call`, metadata `load`, and remote `stop`.
|
|
34
|
+
- `OpenToolJsonLoader` parses OpenTool JSON specs and resolves `$ref` schema references.
|
|
35
|
+
- End-to-end examples in `example/server.py` and `example/client.py` demonstrate CRUD + streaming usage.
|
|
36
|
+
|
|
37
|
+
## Requirements
|
|
38
|
+
|
|
39
|
+
- Python `>=3.10`
|
|
40
|
+
- Schema validation uses `jsonschema` and `referencing`, installed with the SDK.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
Install from PyPI:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
python -m pip install opentool-python
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Install as editable package in local development:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
pip install -e .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or install with source path from another project:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
pip install /absolute/path/to/opentool-python
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick Start
|
|
63
|
+
|
|
64
|
+
### Implement a Tool
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
from opentool_python.json_loader import OpenToolJsonLoader
|
|
69
|
+
from opentool_python.opentool_server import (
|
|
70
|
+
CliTool,
|
|
71
|
+
EventType,
|
|
72
|
+
FunctionNotSupportedException,
|
|
73
|
+
OpenTool,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MockTool(CliTool):
|
|
78
|
+
def call(self, name, arguments):
|
|
79
|
+
if name == "count":
|
|
80
|
+
return {"count": 1}
|
|
81
|
+
return FunctionNotSupportedException(function_name=name).to_json()
|
|
82
|
+
|
|
83
|
+
def stream_call(self, name, arguments, on_event):
|
|
84
|
+
if name == "sequentiallyRead":
|
|
85
|
+
on_event(EventType.DATA, {"data": "Hello"})
|
|
86
|
+
else:
|
|
87
|
+
on_event(
|
|
88
|
+
EventType.ERROR,
|
|
89
|
+
FunctionNotSupportedException(function_name=name).to_json(),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def load(self) -> OpenTool | None:
|
|
93
|
+
return OpenToolJsonLoader().load_from_file(
|
|
94
|
+
str(Path("example/mock_tool.json"))
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Launch the server
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
PYTHONPATH=src python example/server.py
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`CliArguments` can pass built-in and custom CLI switches into your `CliTool.init_args`, so you can preload state before handlers accept requests.
|
|
105
|
+
|
|
106
|
+
### Call the tool from Python
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from opentool_python.opentool_client import FunctionCall, HostType, OpenToolClient
|
|
110
|
+
|
|
111
|
+
client = OpenToolClient(
|
|
112
|
+
tool_host=HostType.LOCALHOST,
|
|
113
|
+
tool_port=17002,
|
|
114
|
+
tool_api_key="your-key",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
print(client.version().to_json())
|
|
118
|
+
print(client.call(FunctionCall(id="call-0", name="count", arguments={})).to_json())
|
|
119
|
+
|
|
120
|
+
client.stream_call(
|
|
121
|
+
FunctionCall(id="call-1", name="sequentiallyRead", arguments={}),
|
|
122
|
+
lambda event, tool_return: print(event, tool_return.result),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
print(client.load().to_json())
|
|
126
|
+
print(client.stop().to_json())
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## CLI and HTTP Reference
|
|
130
|
+
|
|
131
|
+
- Base path defaults to `/opentool`.
|
|
132
|
+
- Endpoints:
|
|
133
|
+
- `GET /version`
|
|
134
|
+
- `POST /call`
|
|
135
|
+
- `POST /streamCall`
|
|
136
|
+
- `GET /load` returns HTTP 404 with code `TOOL_NOT_FOUND` when metadata is unavailable.
|
|
137
|
+
- `POST /stop`
|
|
138
|
+
- Built-in CLI flags (via `CliArguments`):
|
|
139
|
+
- `--opentoolServerTag`
|
|
140
|
+
- `--opentoolServerHost`
|
|
141
|
+
- `--opentoolServerPort`
|
|
142
|
+
- repeatable `--opentoolServerApiKeys`
|
|
143
|
+
- Streaming protocol is Server-Sent Events with event names `start`, `data`, `error`, and `done`.
|
|
144
|
+
|
|
145
|
+
## JSON Specification Loader
|
|
146
|
+
|
|
147
|
+
- Import `opentool_python.opentool_schema` if you only need spec models and parsing.
|
|
148
|
+
- `OpenToolJsonLoader().load_from_file(path)` loads local files and resolves `$ref` entries under `#/schemas/*`.
|
|
149
|
+
- `OpenToolJsonLoader().load(json_string)` loads from raw JSON strings.
|
|
150
|
+
- Specification reference: [OpenTool Spec](https://github.com/opentool-hub/opentool-spec).
|
|
151
|
+
|
|
152
|
+
Schemas default to JSON Schema Draft 2020-12. A recognized `$schema` selects its declared draft. `Schema.to_json()` returns a dictionary or boolean and preserves references, annotations, and extension keywords. Named `#/schemas/...` references are scoped to each OpenTool document, including documents loaded over HTTP.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
from opentool_python.opentool_schema import Schema
|
|
156
|
+
|
|
157
|
+
schema = Schema.from_json({"type": "string", "minLength": 3})
|
|
158
|
+
result = schema.validate("ab")
|
|
159
|
+
print(result.is_valid, result.errors)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Use `validate_formats=True` to assert formats. To supply external schemas, pass a synchronous `schema_reference_resolver` callback to `OpenTool.from_json`, `OpenToolJsonLoader`, or `OpenToolClient`. The callback receives the resource URI and returns an object, a boolean, or `None` if unavailable.
|
|
163
|
+
|
|
164
|
+
## Examples and Development
|
|
165
|
+
|
|
166
|
+
- Example files:
|
|
167
|
+
- `example/server.py`
|
|
168
|
+
- `example/client.py`
|
|
169
|
+
- `example/mock_tool.json`
|
|
170
|
+
- Common local workflow:
|
|
171
|
+
|
|
172
|
+
```sh
|
|
173
|
+
python -m compileall src example
|
|
174
|
+
PYTHONPATH=src python example/server.py
|
|
175
|
+
PYTHONPATH=src python example/client.py
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Compatibility Note
|
|
179
|
+
|
|
180
|
+
This Python SDK is currently a baseline implementation focused on API shape and protocol compatibility with `opentool-dart`.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
opentool_python/__init__.py,sha256=o6No_JnyXS_uji6WUfIAQL5fl5biCYcudoXh4EyewYw,1528
|
|
2
|
+
opentool_python/dto.py,sha256=bfSkUaSDmckzPfLCjW6gFggzUx-GITVY4e-6h0lHQQ0,3547
|
|
3
|
+
opentool_python/json_loader.py,sha256=7OWRgYSK-gX74HgRYi9iOJ0bgnn_6dYeT6Wqa9BjHZ8,827
|
|
4
|
+
opentool_python/opentool_client.py,sha256=tpMBNmU-zY0TNSVF_D3BECFX7qzVlH-tvJghuXtT3C0,683
|
|
5
|
+
opentool_python/opentool_schema.py,sha256=uWlwSAPNGt7AbBtKMgAzic85LRZ9ezqKmGQaJ0Cs9Rw,742
|
|
6
|
+
opentool_python/opentool_server.py,sha256=2QAos7OsOxBsti9Wf2SDrkcZ6Rag23r33Gf7ZCjNh4c,478
|
|
7
|
+
opentool_python/client/__init__.py,sha256=bJKMc95Eza--_rbFmtDC6aTq9Amvtl1hLZzECk1K3sU,465
|
|
8
|
+
opentool_python/client/exceptions.py,sha256=0VfSsbfvZ-jaFR9R6CT8go-68TOptm-nqjgGq_Rzn1Y,1160
|
|
9
|
+
opentool_python/client/opentool_client.py,sha256=etuAbifZGmN_1YBqvDyVwe37XctDDRnhzJVNzLre4Wc,8885
|
|
10
|
+
opentool_python/llm/__init__.py,sha256=n0pyI255rPgtim_Tu5Ale0uOCSBa6R6UrSX1TZTWfSw,86
|
|
11
|
+
opentool_python/llm/model.py,sha256=KI45fyu2rVzkr2sfA_6wWd3iUFkgWmz-jBDxYFdtlSA,852
|
|
12
|
+
opentool_python/model/__init__.py,sha256=8KsoqayQiE13rb9mwL5cltMtwAb8swmyB9CGFZExX4I,577
|
|
13
|
+
opentool_python/model/data_type.py,sha256=SQgw0ACF6oYB-NRGQ4SPGBnmVMPfdl6u3t7dYlsoZwA,174
|
|
14
|
+
opentool_python/model/function_model.py,sha256=L3gY-d3FZ3sd_hTm0lEKSllidfKY_Ieglmy9J68ku1Q,2411
|
|
15
|
+
opentool_python/model/info.py,sha256=0X_DdH7ObPAStsLDUOboEQDfKSmofqh0O6r3dz8lAtQ,659
|
|
16
|
+
opentool_python/model/opentool.py,sha256=VK2Ddb1gF_W5NDrsjX5s7hrjtJA0G86dPOuuBkl1gHw,2121
|
|
17
|
+
opentool_python/model/parameter.py,sha256=hS1Gi3_DOYimGQgbpgEYUS8wWvca9ylQmhC3sSSP05Y,1141
|
|
18
|
+
opentool_python/model/return_model.py,sha256=HJ5xNUCnL98vOb18OlYI5Glx7VUBSzT-skdeB-TfjIo,799
|
|
19
|
+
opentool_python/model/schema.py,sha256=iWh4XpRRpBLbCRRXeN3USCldzaj2io80K_sMIYCG94g,9525
|
|
20
|
+
opentool_python/model/server.py,sha256=ghBNNa8rtN16N5ojJW6yqaJ4k1JpKrLS7SGyZ8DN99k,546
|
|
21
|
+
opentool_python/server/__init__.py,sha256=1sMtmoPqqNyR2LLG4VNql65D-AxGiiBgDCnSeyg-JLA,189
|
|
22
|
+
opentool_python/server/cli_arguments.py,sha256=FsM_bzjI0Z-jfJF5oGurjPz_qacw18316r1-1_7VJeU,1356
|
|
23
|
+
opentool_python/server/cli_tool.py,sha256=veoJLpA0CXDuVwXSTL0QqeREOYCJCP-r_HSXQMsd4po,211
|
|
24
|
+
opentool_python/server/exceptions.py,sha256=pcYmg7YmLapHIUvq1cvbgj-b70unOQAqyUJ-8J96ACg,719
|
|
25
|
+
opentool_python/server/opentool_server.py,sha256=Oi5SiueRwTB8WjLjZc0awFfWkV6HonJyB5bfz3pyKdM,12698
|
|
26
|
+
opentool_python/tool/__init__.py,sha256=jJV_WE3TjZP9AzeB7EPC2s1l6i4BXlWqclMIvp1sU_c,382
|
|
27
|
+
opentool_python/tool/exceptions.py,sha256=LsiNkkq6nZhlDE6jhJJzN96TVOSBwuqgY9OvOQCqb9Q,1554
|
|
28
|
+
opentool_python/tool/model.py,sha256=kkg0BCCPiBiubgjG-zzIysmuNUnK2O0bzDJvtcHRcpA,505
|
|
29
|
+
opentool_python/tool/tool.py,sha256=Eym4TGbVuzxJQJqqaeQU7L41n55bTUNdKRpR1wG6UKg,677
|
|
30
|
+
opentool_python-0.1.0.dist-info/licenses/LICENSE,sha256=gljelJoITV4eS5oP7Hbp9x4-JmznEJC0Le7Y5j4ecxM,1069
|
|
31
|
+
opentool_python-0.1.0.dist-info/METADATA,sha256=WSwYa9F_iuOW-MqyYGRC77MW4nh3JpS6QFOheh2UnV4,6016
|
|
32
|
+
opentool_python-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
33
|
+
opentool_python-0.1.0.dist-info/top_level.txt,sha256=IM2y5SZDYQ85hRxU3V_jiBOTnWzqwEV-8mXuO1ioVKM,16
|
|
34
|
+
opentool_python-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OpenTool Hub
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|