htbp-sdk 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.
- htbp/__init__.py +37 -0
- htbp/app.py +402 -0
- htbp/describe.py +55 -0
- htbp/errors.py +35 -0
- htbp/http.py +55 -0
- htbp/remote.py +188 -0
- htbp/schema.py +122 -0
- htbp/text_help.py +74 -0
- htbp/tree.py +112 -0
- htbp_sdk-0.1.0.dist-info/METADATA +87 -0
- htbp_sdk-0.1.0.dist-info/RECORD +12 -0
- htbp_sdk-0.1.0.dist-info/WHEEL +4 -0
htbp/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""htbp — HTBP (HTTP Tool Bridge Protocol) server SDK.
|
|
2
|
+
|
|
3
|
+
Expose functions as self-describing HTTP tool endpoints, FastAPI-style::
|
|
4
|
+
|
|
5
|
+
from htbp import Htbp
|
|
6
|
+
|
|
7
|
+
app = Htbp(title="Math tools")
|
|
8
|
+
|
|
9
|
+
@app.tool()
|
|
10
|
+
def add(a: int, b: int = 0) -> int:
|
|
11
|
+
\"\"\"Add two integers.\"\"\"
|
|
12
|
+
return a + b
|
|
13
|
+
|
|
14
|
+
# uvicorn main:app
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .app import Htbp
|
|
18
|
+
from .errors import (
|
|
19
|
+
BadRequestError,
|
|
20
|
+
HtbpError,
|
|
21
|
+
MethodNotAllowedError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
UpstreamError,
|
|
24
|
+
)
|
|
25
|
+
from .schema import ToolContext
|
|
26
|
+
from .text_help import build_text_help
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Htbp",
|
|
30
|
+
"ToolContext",
|
|
31
|
+
"HtbpError",
|
|
32
|
+
"NotFoundError",
|
|
33
|
+
"BadRequestError",
|
|
34
|
+
"MethodNotAllowedError",
|
|
35
|
+
"UpstreamError",
|
|
36
|
+
"build_text_help",
|
|
37
|
+
]
|
htbp/app.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""The Htbp application: FastAPI-style tool registration on a self-describing
|
|
2
|
+
HTBP tree, served as a pure-ASGI app — mirror of the TypeScript SDK's
|
|
3
|
+
core/app.ts dispatch."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import inspect
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
from urllib.parse import quote, unquote
|
|
12
|
+
|
|
13
|
+
from .describe import describe_directory, describe_tool
|
|
14
|
+
from .errors import BadRequestError, HtbpError, MethodNotAllowedError, NotFoundError
|
|
15
|
+
from .http import (
|
|
16
|
+
CORS_HEADERS,
|
|
17
|
+
MAX_JSON_BYTES,
|
|
18
|
+
constant_time_equal,
|
|
19
|
+
dump_json,
|
|
20
|
+
error_body,
|
|
21
|
+
etag_of,
|
|
22
|
+
get_bearer_token,
|
|
23
|
+
if_none_match_matches,
|
|
24
|
+
)
|
|
25
|
+
from .schema import CompiledTool, ToolContext, compile_function, output_json_schema
|
|
26
|
+
from .text_help import build_text_help
|
|
27
|
+
from .tree import (
|
|
28
|
+
EFFECTS,
|
|
29
|
+
DirectoryNode,
|
|
30
|
+
RemoteNode,
|
|
31
|
+
ToolNode,
|
|
32
|
+
add_child,
|
|
33
|
+
ensure_directory,
|
|
34
|
+
path_segments,
|
|
35
|
+
resolve,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Htbp:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
title: str,
|
|
43
|
+
description: str | None = None,
|
|
44
|
+
*,
|
|
45
|
+
auth: dict[str, str] | None = None,
|
|
46
|
+
skill: str | None = None,
|
|
47
|
+
cors: bool = True,
|
|
48
|
+
base_path: str | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
if auth is not None and "bearer_token" not in auth:
|
|
51
|
+
raise ValueError("auth must be {'bearer_token': '...'}.")
|
|
52
|
+
self._auth = auth
|
|
53
|
+
self._cors = cors
|
|
54
|
+
self._base_path = _normalize_base_path(base_path)
|
|
55
|
+
self.root = DirectoryNode(id="", title=title, description=description, skill=skill)
|
|
56
|
+
|
|
57
|
+
# -- registration -----------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def tool(
|
|
60
|
+
self,
|
|
61
|
+
fn: Callable[..., Any] | None = None,
|
|
62
|
+
*,
|
|
63
|
+
name: str | None = None,
|
|
64
|
+
description: str | None = None,
|
|
65
|
+
effect: str = "unknown",
|
|
66
|
+
example: Any = None,
|
|
67
|
+
skill: str | None = None,
|
|
68
|
+
):
|
|
69
|
+
"""Decorator: register a plain function as an HTBP tool.
|
|
70
|
+
|
|
71
|
+
Usable bare (``@app.tool``) or with options (``@app.tool(...)``).
|
|
72
|
+
"""
|
|
73
|
+
if effect not in EFFECTS:
|
|
74
|
+
raise ValueError(f"effect must be one of {EFFECTS}.")
|
|
75
|
+
|
|
76
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
77
|
+
compiled = compile_function(func)
|
|
78
|
+
tool_name = name or func.__name__
|
|
79
|
+
doc = inspect.getdoc(func)
|
|
80
|
+
tool_description = description or (doc.split("\n\n")[0].strip() if doc else None)
|
|
81
|
+
return_annotation = inspect.signature(func).return_annotation
|
|
82
|
+
add_child(
|
|
83
|
+
self.root,
|
|
84
|
+
ToolNode(
|
|
85
|
+
id=tool_name,
|
|
86
|
+
title=tool_name,
|
|
87
|
+
description=tool_description,
|
|
88
|
+
skill=skill,
|
|
89
|
+
effect=effect,
|
|
90
|
+
example=example,
|
|
91
|
+
input_schema=compiled.json_schema,
|
|
92
|
+
output_schema=output_json_schema(return_annotation),
|
|
93
|
+
validate=compiled.validate,
|
|
94
|
+
call=_make_caller(func, compiled),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
return func
|
|
98
|
+
|
|
99
|
+
if fn is not None:
|
|
100
|
+
return decorator(fn)
|
|
101
|
+
return decorator
|
|
102
|
+
|
|
103
|
+
def mount(self, path: str, sub: "Htbp") -> "Htbp":
|
|
104
|
+
"""Graft a sub-app's tree under ``path`` (cascading)."""
|
|
105
|
+
if sub is self:
|
|
106
|
+
raise ValueError("Cannot mount an app into itself.")
|
|
107
|
+
segments = path_segments(path)
|
|
108
|
+
parent = ensure_directory(self.root, segments[:-1])
|
|
109
|
+
child = DirectoryNode(
|
|
110
|
+
id=segments[-1],
|
|
111
|
+
title=sub.root.title,
|
|
112
|
+
description=sub.root.description,
|
|
113
|
+
skill=sub.root.skill,
|
|
114
|
+
children=sub.root.children,
|
|
115
|
+
)
|
|
116
|
+
add_child(parent, child)
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
def remote(
|
|
120
|
+
self,
|
|
121
|
+
path: str,
|
|
122
|
+
help_url: str,
|
|
123
|
+
*,
|
|
124
|
+
headers: dict[str, str] | None = None,
|
|
125
|
+
title: str | None = None,
|
|
126
|
+
description: str | None = None,
|
|
127
|
+
allow_insecure: bool = False,
|
|
128
|
+
) -> "Htbp":
|
|
129
|
+
segments = path_segments(path)
|
|
130
|
+
parent = ensure_directory(self.root, segments[:-1])
|
|
131
|
+
add_child(
|
|
132
|
+
parent,
|
|
133
|
+
RemoteNode(
|
|
134
|
+
id=segments[-1],
|
|
135
|
+
title=title or segments[-1],
|
|
136
|
+
description=description,
|
|
137
|
+
help_url=help_url,
|
|
138
|
+
headers=headers,
|
|
139
|
+
allow_insecure=allow_insecure,
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
# -- ASGI -------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
147
|
+
if scope["type"] == "lifespan":
|
|
148
|
+
await _lifespan(receive, send)
|
|
149
|
+
return
|
|
150
|
+
if scope["type"] != "http":
|
|
151
|
+
raise RuntimeError(f"Unsupported ASGI scope type '{scope['type']}'.")
|
|
152
|
+
try:
|
|
153
|
+
status, headers, body = await self._dispatch(scope, receive)
|
|
154
|
+
except HtbpError as error:
|
|
155
|
+
status, headers, body = self._error_response(error)
|
|
156
|
+
except Exception as error: # noqa: BLE001 - handler errors become 500 envelopes
|
|
157
|
+
status, headers, body = self._error_response(HtbpError(500, "internal_error", str(error)))
|
|
158
|
+
if self._cors:
|
|
159
|
+
for key, value in CORS_HEADERS.items():
|
|
160
|
+
headers.setdefault(key, value)
|
|
161
|
+
await _send_response(send, status, headers, body)
|
|
162
|
+
|
|
163
|
+
def _error_response(self, error: HtbpError) -> tuple[int, dict[str, str], bytes]:
|
|
164
|
+
headers = {"Content-Type": "application/json; charset=utf-8"}
|
|
165
|
+
if error.status == 401:
|
|
166
|
+
headers["WWW-Authenticate"] = "Bearer"
|
|
167
|
+
return error.status, headers, error_body(error.code, error.message, error.details).encode("utf-8")
|
|
168
|
+
|
|
169
|
+
async def _dispatch(self, scope: dict[str, Any], receive: Any) -> tuple[int, dict[str, str], bytes]:
|
|
170
|
+
method = scope["method"]
|
|
171
|
+
headers = {key.decode("latin-1").lower(): value.decode("latin-1") for key, value in scope.get("headers", [])}
|
|
172
|
+
|
|
173
|
+
if method == "OPTIONS" and self._cors:
|
|
174
|
+
return 204, {**CORS_HEADERS, "Access-Control-Max-Age": "86400"}, b""
|
|
175
|
+
|
|
176
|
+
self._authenticate(headers)
|
|
177
|
+
|
|
178
|
+
segments, base = self._request_segments(scope)
|
|
179
|
+
control = None
|
|
180
|
+
if segments and segments[-1] in ("~help", "~skill"):
|
|
181
|
+
control = segments.pop()
|
|
182
|
+
|
|
183
|
+
found = resolve(self.root, segments)
|
|
184
|
+
if found is None:
|
|
185
|
+
raise NotFoundError(f"No HTBP resource at '/{'/'.join(segments)}'.")
|
|
186
|
+
node, sub = found
|
|
187
|
+
resource_path = _resource_path(base, segments)
|
|
188
|
+
|
|
189
|
+
if control == "~skill":
|
|
190
|
+
return await self._serve_skill(method, headers, node, sub, resource_path)
|
|
191
|
+
if control == "~help" or (method == "GET" and not segments):
|
|
192
|
+
return await self._serve_help(method, headers, node, sub, resource_path)
|
|
193
|
+
|
|
194
|
+
if method == "POST":
|
|
195
|
+
return await self._serve_call(scope, receive, headers, node, sub, resource_path)
|
|
196
|
+
if method == "GET":
|
|
197
|
+
raise MethodNotAllowedError(f"GET is not supported here; fetch '{resource_path}/~help' for usage.")
|
|
198
|
+
raise MethodNotAllowedError(f"Method {method} is not supported.")
|
|
199
|
+
|
|
200
|
+
def _authenticate(self, headers: dict[str, str]) -> None:
|
|
201
|
+
if self._auth is None:
|
|
202
|
+
return
|
|
203
|
+
token = get_bearer_token(headers.get("authorization"))
|
|
204
|
+
if not token or not constant_time_equal(token, self._auth["bearer_token"]):
|
|
205
|
+
raise HtbpError(401, "unauthorized", "A valid bearer token is required.")
|
|
206
|
+
|
|
207
|
+
def _request_segments(self, scope: dict[str, Any]) -> tuple[list[str], str]:
|
|
208
|
+
# root_path covers being mounted inside FastAPI/Starlette; an explicit
|
|
209
|
+
# base_path is for serving under a prefix without a mounting frame.
|
|
210
|
+
root_path = scope.get("root_path", "") or ""
|
|
211
|
+
base = _join_base(root_path, self._base_path)
|
|
212
|
+
path = scope["path"]
|
|
213
|
+
rest = path
|
|
214
|
+
if self._base_path:
|
|
215
|
+
inner = path[len(root_path):] if root_path and path.startswith(root_path) else path
|
|
216
|
+
if inner != self._base_path and not inner.startswith(self._base_path + "/"):
|
|
217
|
+
raise NotFoundError(f"No HTBP resource at '{path}'.")
|
|
218
|
+
rest = inner[len(self._base_path):]
|
|
219
|
+
elif root_path and path.startswith(root_path):
|
|
220
|
+
rest = path[len(root_path):]
|
|
221
|
+
segments = [unquote(segment) for segment in rest.split("/") if segment]
|
|
222
|
+
return segments, base
|
|
223
|
+
|
|
224
|
+
async def _serve_help(
|
|
225
|
+
self, method: str, headers: dict[str, str], node: Any, sub: list[str], resource_path: str
|
|
226
|
+
) -> tuple[int, dict[str, str], bytes]:
|
|
227
|
+
if method != "GET":
|
|
228
|
+
raise MethodNotAllowedError("~help only supports GET.")
|
|
229
|
+
has_skill = False
|
|
230
|
+
if isinstance(node, DirectoryNode):
|
|
231
|
+
payload = describe_directory(node)
|
|
232
|
+
await self._enrich_remote_children(node, payload)
|
|
233
|
+
has_skill = node.skill is not None
|
|
234
|
+
elif isinstance(node, ToolNode):
|
|
235
|
+
if sub:
|
|
236
|
+
raise NotFoundError(f"No HTBP resource at '{resource_path}'.")
|
|
237
|
+
payload = describe_tool(node)
|
|
238
|
+
has_skill = node.skill is not None
|
|
239
|
+
else:
|
|
240
|
+
from .remote import remote_describe
|
|
241
|
+
|
|
242
|
+
payload = await remote_describe(node, sub)
|
|
243
|
+
|
|
244
|
+
auth_label = "bearer" if self._auth else "none"
|
|
245
|
+
cache_control = (
|
|
246
|
+
"no-store"
|
|
247
|
+
if payload.get("cachable") is False
|
|
248
|
+
else f"{'private' if self._auth else 'public'}, max-age=300"
|
|
249
|
+
)
|
|
250
|
+
accept = headers.get("accept", "")
|
|
251
|
+
if _prefers_text(accept):
|
|
252
|
+
body = build_text_help(payload, resource_path, auth_label, has_skill)
|
|
253
|
+
content_type = "text/plain; charset=utf-8"
|
|
254
|
+
else:
|
|
255
|
+
body = dump_json(payload) + "\n"
|
|
256
|
+
content_type = "application/json; charset=utf-8"
|
|
257
|
+
if cache_control == "no-store":
|
|
258
|
+
return 200, {"Content-Type": content_type, "Cache-Control": cache_control}, body.encode("utf-8")
|
|
259
|
+
return _cached_document(body, content_type, cache_control, headers.get("if-none-match"))
|
|
260
|
+
|
|
261
|
+
async def _enrich_remote_children(self, node: DirectoryNode, payload: dict[str, Any]) -> None:
|
|
262
|
+
remotes = [child for child in node.children.values() if isinstance(child, RemoteNode) and not child.description]
|
|
263
|
+
if not remotes:
|
|
264
|
+
return
|
|
265
|
+
from .remote import fetch_remote_summary
|
|
266
|
+
|
|
267
|
+
refs = {ref["name"]: ref for ref in payload.get("resources", [])}
|
|
268
|
+
summaries = await asyncio.gather(*(fetch_remote_summary(child) for child in remotes))
|
|
269
|
+
for child, summary in zip(remotes, summaries):
|
|
270
|
+
ref = refs.get(child.id)
|
|
271
|
+
if ref is None or summary is None:
|
|
272
|
+
continue
|
|
273
|
+
enriched = summary.get("description") or (f"Remote: {summary['title']}" if summary.get("title") else None)
|
|
274
|
+
if enriched:
|
|
275
|
+
ref["description"] = enriched
|
|
276
|
+
|
|
277
|
+
async def _serve_skill(
|
|
278
|
+
self, method: str, headers: dict[str, str], node: Any, sub: list[str], resource_path: str
|
|
279
|
+
) -> tuple[int, dict[str, str], bytes]:
|
|
280
|
+
if method != "GET":
|
|
281
|
+
raise MethodNotAllowedError("~skill only supports GET.")
|
|
282
|
+
skill = None if isinstance(node, RemoteNode) or sub else getattr(node, "skill", None)
|
|
283
|
+
if skill is None:
|
|
284
|
+
raise NotFoundError(f"No skill document at '{resource_path}/~skill'.")
|
|
285
|
+
cache_control = f"{'private' if self._auth else 'public'}, max-age=300"
|
|
286
|
+
return _cached_document(skill, "text/markdown; charset=utf-8", cache_control, headers.get("if-none-match"))
|
|
287
|
+
|
|
288
|
+
async def _serve_call(
|
|
289
|
+
self, scope: dict[str, Any], receive: Any, headers: dict[str, str], node: Any, sub: list[str], resource_path: str
|
|
290
|
+
) -> tuple[int, dict[str, str], bytes]:
|
|
291
|
+
if isinstance(node, DirectoryNode):
|
|
292
|
+
raise HtbpError(405, "not_callable", f"'{resource_path}' is a directory; descend to an end-path resource.")
|
|
293
|
+
input_value = await _read_json_body(receive)
|
|
294
|
+
if isinstance(node, RemoteNode):
|
|
295
|
+
from .remote import remote_call
|
|
296
|
+
|
|
297
|
+
result = await remote_call(node, sub, input_value)
|
|
298
|
+
return _json_response({"resource": resource_path, "result": result})
|
|
299
|
+
if sub:
|
|
300
|
+
raise NotFoundError(f"No HTBP resource at '{resource_path}'.")
|
|
301
|
+
ok, value = node.validate(input_value)
|
|
302
|
+
if not ok:
|
|
303
|
+
raise BadRequestError("Input validation failed.", value)
|
|
304
|
+
ctx = ToolContext(path=resource_path, headers=headers, scope=scope)
|
|
305
|
+
result = await node.call(value, ctx)
|
|
306
|
+
return _json_response({"resource": resource_path, "result": result})
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _make_caller(fn: Callable[..., Any], compiled: CompiledTool):
|
|
310
|
+
is_async = inspect.iscoroutinefunction(fn)
|
|
311
|
+
|
|
312
|
+
async def call(value: Any, ctx: ToolContext) -> Any:
|
|
313
|
+
kwargs = compiled.bind(value, ctx)
|
|
314
|
+
if is_async:
|
|
315
|
+
return await fn(**kwargs)
|
|
316
|
+
return await asyncio.to_thread(fn, **kwargs)
|
|
317
|
+
|
|
318
|
+
return call
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _prefers_text(accept: str) -> bool:
|
|
322
|
+
value = accept.lower()
|
|
323
|
+
if "application/json" in value:
|
|
324
|
+
return False
|
|
325
|
+
return "text/plain" in value or "text/markdown" in value
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _normalize_base_path(base_path: str | None) -> str:
|
|
329
|
+
if not base_path or base_path == "/":
|
|
330
|
+
return ""
|
|
331
|
+
trimmed = base_path.rstrip("/")
|
|
332
|
+
return trimmed if trimmed.startswith("/") else f"/{trimmed}"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _join_base(root_path: str, base_path: str) -> str:
|
|
336
|
+
combined = f"{root_path.rstrip('/')}{base_path}"
|
|
337
|
+
return combined if combined else ""
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _resource_path(base: str, segments: list[str]) -> str:
|
|
341
|
+
joined = "/".join(quote(segment, safe="") for segment in segments)
|
|
342
|
+
if not joined:
|
|
343
|
+
return base or "/"
|
|
344
|
+
return f"{base}/{joined}"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _cached_document(
|
|
348
|
+
body: str, content_type: str, cache_control: str, if_none_match: str | None
|
|
349
|
+
) -> tuple[int, dict[str, str], bytes]:
|
|
350
|
+
etag = etag_of(body)
|
|
351
|
+
headers = {"ETag": etag, "Cache-Control": cache_control}
|
|
352
|
+
if if_none_match and if_none_match_matches(if_none_match, etag):
|
|
353
|
+
return 304, headers, b""
|
|
354
|
+
return 200, {**headers, "Content-Type": content_type}, body.encode("utf-8")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _json_response(data: Any) -> tuple[int, dict[str, str], bytes]:
|
|
358
|
+
return 200, {"Content-Type": "application/json; charset=utf-8"}, dump_json(data).encode("utf-8")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
async def _read_json_body(receive: Any):
|
|
362
|
+
chunks: list[bytes] = []
|
|
363
|
+
total = 0
|
|
364
|
+
while True:
|
|
365
|
+
message = await receive()
|
|
366
|
+
if message["type"] != "http.request":
|
|
367
|
+
raise BadRequestError("Unexpected ASGI message while reading the body.")
|
|
368
|
+
chunk = message.get("body", b"")
|
|
369
|
+
total += len(chunk)
|
|
370
|
+
if total > MAX_JSON_BYTES:
|
|
371
|
+
raise BadRequestError("Request body exceeds the maximum size.")
|
|
372
|
+
chunks.append(chunk)
|
|
373
|
+
if not message.get("more_body", False):
|
|
374
|
+
break
|
|
375
|
+
body = b"".join(chunks).decode("utf-8", errors="replace")
|
|
376
|
+
if not body.strip():
|
|
377
|
+
return {}
|
|
378
|
+
try:
|
|
379
|
+
return json.loads(body)
|
|
380
|
+
except json.JSONDecodeError:
|
|
381
|
+
raise BadRequestError("Request body is not valid JSON.") from None
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
async def _lifespan(receive: Any, send: Any) -> None:
|
|
385
|
+
while True:
|
|
386
|
+
message = await receive()
|
|
387
|
+
if message["type"] == "lifespan.startup":
|
|
388
|
+
await send({"type": "lifespan.startup.complete"})
|
|
389
|
+
elif message["type"] == "lifespan.shutdown":
|
|
390
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def _send_response(send: Any, status: int, headers: dict[str, str], body: bytes) -> None:
|
|
395
|
+
await send(
|
|
396
|
+
{
|
|
397
|
+
"type": "http.response.start",
|
|
398
|
+
"status": status,
|
|
399
|
+
"headers": [(key.encode("latin-1"), value.encode("latin-1")) for key, value in headers.items()],
|
|
400
|
+
}
|
|
401
|
+
)
|
|
402
|
+
await send({"type": "http.response.body", "body": body})
|
htbp/describe.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Node -> help payload projection (mirror of core/describe.ts).
|
|
2
|
+
|
|
3
|
+
Payload dicts preserve the TypeScript key order so serialized output matches
|
|
4
|
+
across the two SDKs. Tool leaves are emitted as kind ``http`` for wire
|
|
5
|
+
compatibility with tool-bridge's remote-node sanitizer."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from .tree import AppNode, DirectoryNode, RemoteNode, ToolNode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def describe_directory(node: DirectoryNode) -> dict[str, Any]:
|
|
16
|
+
resources = []
|
|
17
|
+
for child in node.children.values():
|
|
18
|
+
ref: dict[str, Any] = {
|
|
19
|
+
"name": child.id,
|
|
20
|
+
# INVARIANT: relative path, so the subtree stays mountable anywhere.
|
|
21
|
+
"path": f"./{quote(child.id, safe='')}",
|
|
22
|
+
}
|
|
23
|
+
description = _child_description(child)
|
|
24
|
+
if description is not None:
|
|
25
|
+
ref["description"] = description
|
|
26
|
+
resources.append(ref)
|
|
27
|
+
payload: dict[str, Any] = {"htbp": "draft", "kind": "directory", "title": node.title}
|
|
28
|
+
if node.description is not None:
|
|
29
|
+
payload["description"] = node.description
|
|
30
|
+
payload["resources"] = resources
|
|
31
|
+
return payload
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _child_description(child: AppNode) -> str | None:
|
|
35
|
+
if child.description:
|
|
36
|
+
return child.description
|
|
37
|
+
if isinstance(child, RemoteNode):
|
|
38
|
+
return "Remote HTBP server."
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def describe_tool(node: ToolNode) -> dict[str, Any]:
|
|
43
|
+
endpoint: dict[str, Any] = {"method": "POST"}
|
|
44
|
+
if node.input_schema is not None:
|
|
45
|
+
endpoint["inputSchema"] = node.input_schema
|
|
46
|
+
if node.output_schema is not None:
|
|
47
|
+
endpoint["outputSchema"] = node.output_schema
|
|
48
|
+
if node.example is not None:
|
|
49
|
+
endpoint["example"] = node.example
|
|
50
|
+
endpoint["effect"] = node.effect
|
|
51
|
+
payload: dict[str, Any] = {"htbp": "draft", "kind": "http", "title": node.title}
|
|
52
|
+
if node.description is not None:
|
|
53
|
+
payload["description"] = node.description
|
|
54
|
+
payload["endpoint"] = endpoint
|
|
55
|
+
return payload
|
htbp/errors.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Error hierarchy mapped to the HTBP error envelope
|
|
2
|
+
``{"error":{"code","message","details?"}}`` with an HTTP status."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HtbpError(Exception):
|
|
10
|
+
def __init__(self, status: int, code: str, message: str, details: Any = None) -> None:
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.status = status
|
|
13
|
+
self.code = code
|
|
14
|
+
self.message = message
|
|
15
|
+
self.details = details
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NotFoundError(HtbpError):
|
|
19
|
+
def __init__(self, message: str) -> None:
|
|
20
|
+
super().__init__(404, "not_found", message)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MethodNotAllowedError(HtbpError):
|
|
24
|
+
def __init__(self, message: str) -> None:
|
|
25
|
+
super().__init__(405, "method_not_allowed", message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BadRequestError(HtbpError):
|
|
29
|
+
def __init__(self, message: str, details: Any = None) -> None:
|
|
30
|
+
super().__init__(400, "bad_request", message, details)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UpstreamError(HtbpError):
|
|
34
|
+
def __init__(self, message: str, details: Any = None) -> None:
|
|
35
|
+
super().__init__(502, "upstream_error", message, details)
|
htbp/http.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""HTTP plumbing shared by the ASGI handler and the remote client — mirror of
|
|
2
|
+
the TypeScript SDK's core/http.ts."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json as _json
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
MAX_JSON_BYTES = 1_000_000
|
|
13
|
+
REMOTE_FETCH_TIMEOUT_S = 5.0
|
|
14
|
+
|
|
15
|
+
CORS_HEADERS = {
|
|
16
|
+
"Access-Control-Allow-Origin": "*",
|
|
17
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
18
|
+
"Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, If-None-Match",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def dump_json(data: Any) -> str:
|
|
23
|
+
# Matches JSON.stringify(data, null, 2): 2-space indent, ": " separator,
|
|
24
|
+
# no ASCII escaping of non-ASCII characters.
|
|
25
|
+
return _json.dumps(data, indent=2, ensure_ascii=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def error_body(code: str, message: str, details: Any = None) -> str:
|
|
29
|
+
error: dict[str, Any] = {"code": code, "message": message}
|
|
30
|
+
if details is not None:
|
|
31
|
+
error["details"] = details
|
|
32
|
+
return dump_json({"error": error})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def etag_of(body: str) -> str:
|
|
36
|
+
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
37
|
+
return f'"{digest[:16]}"'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def if_none_match_matches(header: str, etag: str) -> bool:
|
|
41
|
+
candidates = [candidate.strip() for candidate in header.split(",")]
|
|
42
|
+
return any(candidate == etag or candidate == "*" for candidate in candidates)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def constant_time_equal(a: str, b: str) -> bool:
|
|
46
|
+
hash_a = hashlib.sha256(a.encode("utf-8")).hexdigest()
|
|
47
|
+
hash_b = hashlib.sha256(b.encode("utf-8")).hexdigest()
|
|
48
|
+
return hmac.compare_digest(hash_a, hash_b)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_bearer_token(authorization: str | None) -> str | None:
|
|
52
|
+
if not authorization:
|
|
53
|
+
return None
|
|
54
|
+
match = re.match(r"^Bearer\s+(.+)$", authorization.strip(), re.IGNORECASE)
|
|
55
|
+
return match.group(1) if match else None
|
htbp/remote.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Remote HTBP node: pass through ``~help`` for any sub-path and proxy calls,
|
|
2
|
+
never forwarding local credentials — mirror of the TypeScript SDK's
|
|
3
|
+
core/remote.ts. Requires the ``htbp-sdk[remote]`` extra (httpx)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import quote, urlsplit
|
|
11
|
+
|
|
12
|
+
from .errors import HtbpError, NotFoundError, UpstreamError
|
|
13
|
+
from .http import MAX_JSON_BYTES, REMOTE_FETCH_TIMEOUT_S
|
|
14
|
+
from .tree import EFFECTS, RemoteNode
|
|
15
|
+
|
|
16
|
+
_VALID_KINDS = ("directory", "http", "remote")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _httpx():
|
|
20
|
+
try:
|
|
21
|
+
import httpx
|
|
22
|
+
except ImportError as error: # pragma: no cover
|
|
23
|
+
raise RuntimeError(
|
|
24
|
+
"Remote nodes require httpx; install the extra: pip install 'htbp-sdk[remote]'."
|
|
25
|
+
) from error
|
|
26
|
+
return httpx
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def remote_base_url(help_url: str) -> str:
|
|
30
|
+
return re.sub(r"/?~help/?$", "", help_url).rstrip("/")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _require_secure_url(node: RemoteNode, raw: str) -> str:
|
|
34
|
+
parts = urlsplit(raw)
|
|
35
|
+
if parts.scheme not in ("http", "https"):
|
|
36
|
+
raise UpstreamError(f"Remote URL '{raw}' must be http(s).")
|
|
37
|
+
host = parts.hostname or ""
|
|
38
|
+
is_local = host in ("localhost", "127.0.0.1", "::1")
|
|
39
|
+
if parts.scheme != "https" and not (node.allow_insecure or is_local):
|
|
40
|
+
raise UpstreamError(f"Remote URL '{raw}' must use https (set allow_insecure for development).")
|
|
41
|
+
return raw
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _remote_target(node: RemoteNode, sub: list[str]) -> str:
|
|
45
|
+
for segment in sub:
|
|
46
|
+
if segment in (".", "..") or not segment:
|
|
47
|
+
raise NotFoundError(f"Invalid remote sub-path segment '{segment}'.")
|
|
48
|
+
base = remote_base_url(node.help_url)
|
|
49
|
+
suffix = "/".join(quote(segment, safe="") for segment in sub)
|
|
50
|
+
return _require_secure_url(node, f"{base}/{suffix}" if suffix else base)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _remote_headers(node: RemoteNode, extra: dict[str, str]) -> dict[str, str]:
|
|
54
|
+
# Only the per-remote configured headers ever go upstream; the inbound
|
|
55
|
+
# Authorization header is deliberately never forwarded.
|
|
56
|
+
headers = dict(node.headers or {})
|
|
57
|
+
headers.update(extra)
|
|
58
|
+
return headers
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _bounded_request(method: str, url: str, headers: dict[str, str], content: str | None, timeout: float) -> tuple[int, str]:
|
|
62
|
+
httpx = _httpx()
|
|
63
|
+
async with httpx.AsyncClient(follow_redirects=False, timeout=timeout) as client:
|
|
64
|
+
response = await client.request(method, url, headers=headers, content=content)
|
|
65
|
+
if 300 <= response.status_code < 400:
|
|
66
|
+
raise UpstreamError(f"Remote '{url}' responded with a redirect ({response.status_code}); redirects are not followed.")
|
|
67
|
+
body = response.text
|
|
68
|
+
if len(body.encode("utf-8")) > MAX_JSON_BYTES:
|
|
69
|
+
raise UpstreamError("Remote response body exceeded the maximum size.")
|
|
70
|
+
return response.status_code, body
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def remote_describe(node: RemoteNode, sub: list[str]) -> dict[str, Any]:
|
|
74
|
+
target = _remote_target(node, sub)
|
|
75
|
+
help_url = f"{target.rstrip('/')}/~help"
|
|
76
|
+
try:
|
|
77
|
+
status, body = await _bounded_request(
|
|
78
|
+
"GET", help_url, _remote_headers(node, {"Accept": "application/json"}), None, REMOTE_FETCH_TIMEOUT_S
|
|
79
|
+
)
|
|
80
|
+
except HtbpError:
|
|
81
|
+
raise
|
|
82
|
+
except Exception as error:
|
|
83
|
+
raise UpstreamError(f"Failed to fetch remote help: {error}") from error
|
|
84
|
+
if status == 404:
|
|
85
|
+
raise NotFoundError(f"Remote resource '/{'/'.join(sub)}' not found.")
|
|
86
|
+
if status != 200:
|
|
87
|
+
raise UpstreamError(f"Remote help returned {status}.", body[:2000] or None)
|
|
88
|
+
try:
|
|
89
|
+
payload = parse_help_payload(body)
|
|
90
|
+
except Exception as error:
|
|
91
|
+
raise UpstreamError(f"Invalid remote help payload: {error}") from error
|
|
92
|
+
if not sub:
|
|
93
|
+
payload["kind"] = "remote"
|
|
94
|
+
payload["title"] = node.title or payload["title"]
|
|
95
|
+
if node.description is not None:
|
|
96
|
+
payload["description"] = node.description
|
|
97
|
+
return payload
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def remote_call(node: RemoteNode, sub: list[str], input_value: Any) -> Any:
|
|
101
|
+
target = _remote_target(node, sub)
|
|
102
|
+
try:
|
|
103
|
+
status, body = await _bounded_request(
|
|
104
|
+
"POST",
|
|
105
|
+
target,
|
|
106
|
+
_remote_headers(node, {"Content-Type": "application/json", "Accept": "application/json"}),
|
|
107
|
+
json.dumps(input_value if input_value is not None else {}),
|
|
108
|
+
REMOTE_FETCH_TIMEOUT_S,
|
|
109
|
+
)
|
|
110
|
+
except HtbpError:
|
|
111
|
+
raise
|
|
112
|
+
except Exception as error:
|
|
113
|
+
raise UpstreamError(f"Remote call failed: {error}") from error
|
|
114
|
+
try:
|
|
115
|
+
parsed = json.loads(body) if body else None
|
|
116
|
+
except json.JSONDecodeError:
|
|
117
|
+
raise UpstreamError(f"Remote call returned non-JSON (status {status}).") from None
|
|
118
|
+
if status != 200:
|
|
119
|
+
if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict):
|
|
120
|
+
error = parsed["error"]
|
|
121
|
+
if isinstance(error.get("code"), str) and isinstance(error.get("message"), str):
|
|
122
|
+
raise HtbpError(status, error["code"], error["message"], error.get("details"))
|
|
123
|
+
raise UpstreamError(f"Remote call returned {status}.", parsed)
|
|
124
|
+
if isinstance(parsed, dict) and "result" in parsed and "resource" in parsed:
|
|
125
|
+
return parsed["result"]
|
|
126
|
+
return parsed
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def fetch_remote_summary(node: RemoteNode, timeout: float = 1.5) -> dict[str, Any] | None:
|
|
130
|
+
"""Best-effort title/description for directory listings; None on failure."""
|
|
131
|
+
try:
|
|
132
|
+
help_url = f"{remote_base_url(node.help_url)}/~help"
|
|
133
|
+
_require_secure_url(node, help_url)
|
|
134
|
+
status, body = await _bounded_request(
|
|
135
|
+
"GET", help_url, _remote_headers(node, {"Accept": "application/json"}), None, timeout
|
|
136
|
+
)
|
|
137
|
+
if status != 200:
|
|
138
|
+
return None
|
|
139
|
+
payload = parse_help_payload(body)
|
|
140
|
+
return {"title": payload.get("title"), "description": payload.get("description")}
|
|
141
|
+
except Exception:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def parse_help_payload(body: str) -> dict[str, Any]:
|
|
146
|
+
"""Tolerantly validate an untrusted JSON help payload into our shape."""
|
|
147
|
+
parsed = json.loads(body)
|
|
148
|
+
if not isinstance(parsed, dict):
|
|
149
|
+
raise ValueError("Remote help payload is not a JSON object.")
|
|
150
|
+
kind = parsed.get("kind") if parsed.get("kind") in _VALID_KINDS else "directory"
|
|
151
|
+
payload: dict[str, Any] = {
|
|
152
|
+
"htbp": "draft",
|
|
153
|
+
"kind": kind,
|
|
154
|
+
"title": parsed["title"] if isinstance(parsed.get("title"), str) else "Remote HTBP server",
|
|
155
|
+
}
|
|
156
|
+
if isinstance(parsed.get("description"), str):
|
|
157
|
+
payload["description"] = parsed["description"]
|
|
158
|
+
if isinstance(parsed.get("cachable"), bool):
|
|
159
|
+
payload["cachable"] = parsed["cachable"]
|
|
160
|
+
resources = _parse_resources(parsed.get("resources"))
|
|
161
|
+
if resources is not None:
|
|
162
|
+
payload["resources"] = resources
|
|
163
|
+
if isinstance(parsed.get("endpoint"), dict):
|
|
164
|
+
payload["endpoint"] = _sanitize_endpoint(parsed["endpoint"])
|
|
165
|
+
return payload
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _parse_resources(value: Any) -> list[dict[str, Any]] | None:
|
|
169
|
+
if not isinstance(value, list):
|
|
170
|
+
return None
|
|
171
|
+
refs = []
|
|
172
|
+
for item in value:
|
|
173
|
+
if isinstance(item, dict) and isinstance(item.get("name"), str) and isinstance(item.get("path"), str):
|
|
174
|
+
ref: dict[str, Any] = {"name": item["name"], "path": item["path"]}
|
|
175
|
+
if isinstance(item.get("description"), str):
|
|
176
|
+
ref["description"] = item["description"]
|
|
177
|
+
refs.append(ref)
|
|
178
|
+
return refs
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _sanitize_endpoint(value: dict[str, Any]) -> dict[str, Any]:
|
|
182
|
+
endpoint: dict[str, Any] = {"method": value["method"] if isinstance(value.get("method"), str) else "POST"}
|
|
183
|
+
for key in ("inputSchema", "outputSchema", "example"):
|
|
184
|
+
if key in value:
|
|
185
|
+
endpoint[key] = value[key]
|
|
186
|
+
if value.get("effect") in EFFECTS:
|
|
187
|
+
endpoint["effect"] = value["effect"]
|
|
188
|
+
return endpoint
|
htbp/schema.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Function signature -> pydantic model -> JSON Schema, FastAPI-style.
|
|
2
|
+
|
|
3
|
+
Rules:
|
|
4
|
+
- Every regular parameter becomes an input field; type hints map to pydantic
|
|
5
|
+
field types (missing hints default to Any), defaults make fields optional.
|
|
6
|
+
- A parameter annotated with ``ToolContext`` is injected at call time and
|
|
7
|
+
excluded from the schema.
|
|
8
|
+
- Exactly one non-context parameter annotated with a ``BaseModel`` subclass
|
|
9
|
+
makes that model the input schema directly (the "body model" style).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import inspect
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, get_type_hints
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, ValidationError, create_model
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ToolContext:
|
|
23
|
+
"""Request context injected into ToolContext-annotated parameters."""
|
|
24
|
+
|
|
25
|
+
path: str
|
|
26
|
+
headers: dict[str, str]
|
|
27
|
+
scope: dict[str, Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CompiledTool:
|
|
32
|
+
json_schema: dict[str, Any] | None
|
|
33
|
+
validate: Any # (input) -> (ok, value_or_issues)
|
|
34
|
+
bind: Any # (validated_value, ctx) -> kwargs dict for fn
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compile_function(fn: Any) -> CompiledTool:
|
|
38
|
+
signature = inspect.signature(fn)
|
|
39
|
+
try:
|
|
40
|
+
hints = get_type_hints(fn)
|
|
41
|
+
except Exception:
|
|
42
|
+
hints = {}
|
|
43
|
+
|
|
44
|
+
ctx_params: list[str] = []
|
|
45
|
+
field_params: list[inspect.Parameter] = []
|
|
46
|
+
for param in signature.parameters.values():
|
|
47
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
48
|
+
raise TypeError(f"Tool '{fn.__name__}' cannot use *args/**kwargs parameters.")
|
|
49
|
+
annotation = hints.get(param.name, param.annotation)
|
|
50
|
+
if annotation is ToolContext:
|
|
51
|
+
ctx_params.append(param.name)
|
|
52
|
+
else:
|
|
53
|
+
field_params.append(param)
|
|
54
|
+
|
|
55
|
+
body_model = _single_body_model(field_params, hints)
|
|
56
|
+
if body_model is not None:
|
|
57
|
+
model, body_param = body_model
|
|
58
|
+
return CompiledTool(
|
|
59
|
+
json_schema=model.model_json_schema(),
|
|
60
|
+
validate=_model_validator(model),
|
|
61
|
+
bind=lambda value, ctx: {body_param: value, **{name: ctx for name in ctx_params}},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
fields: dict[str, Any] = {}
|
|
65
|
+
for param in field_params:
|
|
66
|
+
annotation = hints.get(param.name, param.annotation)
|
|
67
|
+
if annotation is inspect.Parameter.empty:
|
|
68
|
+
annotation = Any
|
|
69
|
+
default = ... if param.default is inspect.Parameter.empty else param.default
|
|
70
|
+
fields[param.name] = (annotation, default)
|
|
71
|
+
|
|
72
|
+
if not fields:
|
|
73
|
+
return CompiledTool(
|
|
74
|
+
json_schema=None,
|
|
75
|
+
validate=lambda value: (True, value),
|
|
76
|
+
bind=lambda value, ctx: {name: ctx for name in ctx_params},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
model = create_model(f"{_camel(fn.__name__)}Input", **fields)
|
|
80
|
+
field_names = list(fields)
|
|
81
|
+
return CompiledTool(
|
|
82
|
+
json_schema=model.model_json_schema(),
|
|
83
|
+
validate=_model_validator(model),
|
|
84
|
+
bind=lambda value, ctx: {
|
|
85
|
+
**{name: getattr(value, name) for name in field_names},
|
|
86
|
+
**{name: ctx for name in ctx_params},
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _single_body_model(
|
|
92
|
+
params: list[inspect.Parameter], hints: dict[str, Any]
|
|
93
|
+
) -> tuple[type[BaseModel], str] | None:
|
|
94
|
+
if len(params) != 1:
|
|
95
|
+
return None
|
|
96
|
+
annotation = hints.get(params[0].name, params[0].annotation)
|
|
97
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
98
|
+
return annotation, params[0].name
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _model_validator(model: type[BaseModel]):
|
|
103
|
+
def validate(value: Any) -> tuple[bool, Any]:
|
|
104
|
+
if not isinstance(value, dict):
|
|
105
|
+
return False, [{"type": "model_type", "msg": "Input must be a JSON object."}]
|
|
106
|
+
try:
|
|
107
|
+
return True, model.model_validate(value)
|
|
108
|
+
except ValidationError as error:
|
|
109
|
+
return False, error.errors(include_url=False)
|
|
110
|
+
|
|
111
|
+
return validate
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def output_json_schema(annotation: Any) -> dict[str, Any] | None:
|
|
115
|
+
"""Documentation-only output schema from a BaseModel return annotation."""
|
|
116
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
117
|
+
return annotation.model_json_schema()
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _camel(name: str) -> str:
|
|
122
|
+
return "".join(part.capitalize() for part in name.split("_"))
|
htbp/text_help.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Text/plain HTBP DSL rendering of a JSON help payload — mirror of the
|
|
2
|
+
TypeScript SDK's protocol/text-help.ts (RFC-0001 §8)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
MAX_SHAPE_LENGTH = 120
|
|
10
|
+
_SIMPLE_TYPES = {"string", "integer", "number", "boolean", "object", "array"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def one_line(value: str) -> str:
|
|
14
|
+
return re.sub(r"\s+", " ", value).strip()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_text_help(payload: dict[str, Any], resource_path: str, auth: str, has_skill: bool) -> str:
|
|
18
|
+
lines = [
|
|
19
|
+
"htbp draft",
|
|
20
|
+
f"resource {resource_path}",
|
|
21
|
+
f"title {one_line(payload['title'])}",
|
|
22
|
+
]
|
|
23
|
+
if payload.get("description"):
|
|
24
|
+
lines.append(f"summary {one_line(payload['description'])}")
|
|
25
|
+
if has_skill:
|
|
26
|
+
lines.append("skill ./~skill")
|
|
27
|
+
lines.append(f"auth {auth}")
|
|
28
|
+
lines.append("")
|
|
29
|
+
|
|
30
|
+
endpoint = payload.get("endpoint")
|
|
31
|
+
if endpoint:
|
|
32
|
+
lines.append(f"cmd call {endpoint['method']} {resource_path}")
|
|
33
|
+
lines.append(f" body application/json {compact_shape(endpoint.get('inputSchema'))}")
|
|
34
|
+
lines.append(f" auth {auth}")
|
|
35
|
+
lines.append(f" effect {endpoint.get('effect') or 'unknown'}")
|
|
36
|
+
lines.append(" returns 200 application/json")
|
|
37
|
+
|
|
38
|
+
for resource in payload.get("resources") or []:
|
|
39
|
+
lines.append(f"link child {join_relative(resource_path, resource['path'])}/~help")
|
|
40
|
+
if resource.get("description"):
|
|
41
|
+
lines.append(f" note {one_line(resource['description'])}")
|
|
42
|
+
|
|
43
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def compact_shape(schema: Any) -> str:
|
|
47
|
+
"""Render a JSON Schema as the RFC §8.6 compact body shape, e.g.
|
|
48
|
+
``{"a":integer,"b":integer?}``; fall back to ``object`` when it would not
|
|
49
|
+
stay compact."""
|
|
50
|
+
if not isinstance(schema, dict) or schema.get("type") != "object" or not isinstance(schema.get("properties"), dict):
|
|
51
|
+
return "object"
|
|
52
|
+
required = schema.get("required") if isinstance(schema.get("required"), list) else []
|
|
53
|
+
fields = []
|
|
54
|
+
for name, prop in schema["properties"].items():
|
|
55
|
+
optional = "" if name in required else "?"
|
|
56
|
+
fields.append(f'"{name}":{_shape_type(prop)}{optional}')
|
|
57
|
+
shape = "{" + ",".join(fields) + "}"
|
|
58
|
+
return "object" if len(shape) > MAX_SHAPE_LENGTH else shape
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _shape_type(prop: Any) -> str:
|
|
62
|
+
if isinstance(prop, dict) and prop.get("type") in _SIMPLE_TYPES:
|
|
63
|
+
return prop["type"]
|
|
64
|
+
return "json"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def join_relative(base: str, relative: str) -> str:
|
|
68
|
+
prefix = "" if base == "/" else base.rstrip("/")
|
|
69
|
+
if relative.startswith("./"):
|
|
70
|
+
return f"{prefix}/{relative[2:]}"
|
|
71
|
+
if relative.startswith("../"):
|
|
72
|
+
parent = "/".join(prefix.split("/")[:-1])
|
|
73
|
+
return f"{parent}/{relative[3:]}"
|
|
74
|
+
return f"{prefix}/{relative}"
|
htbp/tree.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""In-memory app tree and path resolution — line-for-line mirror of the
|
|
2
|
+
TypeScript SDK's core/tree.ts (itself a reduction of tool-bridge's
|
|
3
|
+
registry.ts)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Awaitable, Callable, Union
|
|
10
|
+
|
|
11
|
+
Effect = str # 'read' | 'write' | 'delete' | 'external' | 'unknown'
|
|
12
|
+
EFFECTS = ("read", "write", "delete", "external", "unknown")
|
|
13
|
+
|
|
14
|
+
# validate(input) -> (ok, value_or_issues)
|
|
15
|
+
Validator = Callable[[Any], tuple[bool, Any]]
|
|
16
|
+
Handler = Callable[..., Any]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class DirectoryNode:
|
|
21
|
+
id: str
|
|
22
|
+
title: str
|
|
23
|
+
description: str | None = None
|
|
24
|
+
skill: str | None = None
|
|
25
|
+
children: dict[str, "AppNode"] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
kind = "directory"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ToolNode:
|
|
32
|
+
id: str
|
|
33
|
+
title: str
|
|
34
|
+
validate: Validator
|
|
35
|
+
call: Callable[[Any, Any], Awaitable[Any]] # (validated_input, ctx) -> result
|
|
36
|
+
description: str | None = None
|
|
37
|
+
skill: str | None = None
|
|
38
|
+
effect: Effect = "unknown"
|
|
39
|
+
example: Any = None
|
|
40
|
+
input_schema: Any = None
|
|
41
|
+
output_schema: Any = None
|
|
42
|
+
|
|
43
|
+
kind = "tool"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class RemoteNode:
|
|
48
|
+
id: str
|
|
49
|
+
title: str
|
|
50
|
+
help_url: str
|
|
51
|
+
description: str | None = None
|
|
52
|
+
headers: dict[str, str] | None = None
|
|
53
|
+
allow_insecure: bool = False
|
|
54
|
+
|
|
55
|
+
kind = "remote"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
AppNode = Union[DirectoryNode, ToolNode, RemoteNode]
|
|
59
|
+
|
|
60
|
+
_SEGMENT_PATTERN = re.compile(r"^[^/?#]+$")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def assert_valid_segment(segment: str, context: str) -> None:
|
|
64
|
+
if not _SEGMENT_PATTERN.match(segment) or segment in (".", "..") or segment.startswith("~"):
|
|
65
|
+
raise ValueError(f"{context}: '{segment}' is not a valid path segment.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def add_child(parent: DirectoryNode, node: AppNode) -> None:
|
|
69
|
+
assert_valid_segment(node.id, "Node id")
|
|
70
|
+
if node.id in parent.children:
|
|
71
|
+
raise ValueError(f"Duplicate sibling id '{node.id}'.")
|
|
72
|
+
parent.children[node.id] = node
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def path_segments(path: str) -> list[str]:
|
|
76
|
+
segments = [segment for segment in path.split("/") if segment]
|
|
77
|
+
if not segments:
|
|
78
|
+
raise ValueError(f"Mount path '{path}' must contain at least one segment.")
|
|
79
|
+
for segment in segments:
|
|
80
|
+
assert_valid_segment(segment, f"Mount path '{path}'")
|
|
81
|
+
return segments
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ensure_directory(root: DirectoryNode, segments: list[str]) -> DirectoryNode:
|
|
85
|
+
current = root
|
|
86
|
+
for segment in segments:
|
|
87
|
+
existing = current.children.get(segment)
|
|
88
|
+
if existing is not None:
|
|
89
|
+
if not isinstance(existing, DirectoryNode):
|
|
90
|
+
raise ValueError(f"Cannot create directory '{segment}': a {existing.kind} node already uses that id.")
|
|
91
|
+
current = existing
|
|
92
|
+
else:
|
|
93
|
+
created = DirectoryNode(id=segment, title=segment)
|
|
94
|
+
current.children[segment] = created
|
|
95
|
+
current = created
|
|
96
|
+
return current
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve(root: DirectoryNode, segments: list[str]) -> tuple[AppNode, list[str]] | None:
|
|
100
|
+
"""Walk directory children by URL segments; a tool/remote node stops
|
|
101
|
+
descent and the leftover segments become its sub-path."""
|
|
102
|
+
current: AppNode = root
|
|
103
|
+
index = 0
|
|
104
|
+
while index < len(segments):
|
|
105
|
+
if not isinstance(current, DirectoryNode):
|
|
106
|
+
break
|
|
107
|
+
nxt = current.children.get(segments[index])
|
|
108
|
+
if nxt is None:
|
|
109
|
+
return None
|
|
110
|
+
current = nxt
|
|
111
|
+
index += 1
|
|
112
|
+
return current, segments[index:]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: htbp-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: HTBP (HTTP Tool Bridge Protocol) server SDK: expose functions as self-describing HTTP tool endpoints, FastAPI-style.
|
|
5
|
+
Project-URL: Repository, https://github.com/TokenRollAI/http-tool-bridge
|
|
6
|
+
Author: TokenRoll AI
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: agent,asgi,htbp,http,sdk,tool
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: pydantic>=2.7
|
|
11
|
+
Provides-Extra: remote
|
|
12
|
+
Requires-Dist: httpx>=0.27; extra == 'remote'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# htbp-sdk
|
|
16
|
+
|
|
17
|
+
HTBP (HTTP Tool Bridge Protocol) server SDK for Python. Expose functions as
|
|
18
|
+
self-describing HTTP tool endpoints, FastAPI-style. Pure ASGI — runs under
|
|
19
|
+
uvicorn/hypercorn and mounts inside FastAPI or Starlette.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pip install htbp-sdk # import name: htbp
|
|
25
|
+
pip install 'htbp-sdk[remote]' # + httpx, needed only for app.remote()
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from htbp import Htbp
|
|
32
|
+
|
|
33
|
+
app = Htbp(title="Math tools")
|
|
34
|
+
|
|
35
|
+
@app.tool(effect="read")
|
|
36
|
+
def add(a: int, b: int = 0) -> int:
|
|
37
|
+
"""Add two integers."""
|
|
38
|
+
return a + b
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
uvicorn main:app
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Input schemas are derived from type hints via pydantic (FastAPI-style); a
|
|
46
|
+
single parameter annotated with a `BaseModel` subclass uses that model as the
|
|
47
|
+
body schema directly. Async functions are awaited; sync functions run in a
|
|
48
|
+
thread. A `ToolContext`-annotated parameter receives request context.
|
|
49
|
+
|
|
50
|
+
Every node answers `GET {path}/~help` (JSON by default, RFC-0001 text DSL with
|
|
51
|
+
`Accept: text/plain`); tools are invoked with `POST {path}` and respond with
|
|
52
|
+
`{"resource", "result"}`.
|
|
53
|
+
|
|
54
|
+
## Cascading
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
docs = Htbp(title="Docs tools")
|
|
58
|
+
|
|
59
|
+
@docs.tool()
|
|
60
|
+
def search(q: str) -> dict:
|
|
61
|
+
return {"hits": [q]}
|
|
62
|
+
|
|
63
|
+
app.mount("/docs", docs) # local nesting
|
|
64
|
+
app.remote("/ext", "https://other.example.com/~help") # federate a remote HTBP server
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Remote nodes pass through `~help` for any sub-path and proxy calls; the inbound
|
|
68
|
+
`Authorization` header is never forwarded upstream.
|
|
69
|
+
|
|
70
|
+
Mount inside FastAPI:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
fastapi_app.mount("/tools", app)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Options
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
Htbp(
|
|
80
|
+
title="My tools",
|
|
81
|
+
description="...",
|
|
82
|
+
auth={"bearer_token": "..."}, # 401 + WWW-Authenticate without it
|
|
83
|
+
skill="# Markdown served at /~skill",
|
|
84
|
+
base_path="/htbp", # serve under a path prefix
|
|
85
|
+
cors=False, # CORS is on by default
|
|
86
|
+
)
|
|
87
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
htbp/__init__.py,sha256=ghRZBHn8lYI0S6cyTDPhJSJOJBtd4tRqmsQqELENgJI,737
|
|
2
|
+
htbp/app.py,sha256=kbrOfBuAqxNYB0zEu4YELvOIx5Nz2DmCHVz-FLZRSeQ,15911
|
|
3
|
+
htbp/describe.py,sha256=w0klUShz53Iknoi96KWN58XwdZtgv2UVyFRrGx7qS6k,1984
|
|
4
|
+
htbp/errors.py,sha256=EMRfwh7dj38vhbM9MlAPQtDyZB5NlwOVNtQ12zsBro4,1066
|
|
5
|
+
htbp/http.py,sha256=NVlM8VOjzx1C2DbdO2hNKx6Du9Xde7rMjI3mv1C5xUc,1748
|
|
6
|
+
htbp/remote.py,sha256=2SVYWg_MGowlZ7yKiaO98qb_-BxLCMrLMMmyS0Zwq9c,7776
|
|
7
|
+
htbp/schema.py,sha256=74qIwz-gJ8SC3VEkfkaJjlZnwQBpQBDjWRJPNvTFT_U,4169
|
|
8
|
+
htbp/text_help.py,sha256=r3q246yWKAIkvpKRhuEvTkEDCsZjc090rpOs2prdJZw,2747
|
|
9
|
+
htbp/tree.py,sha256=EY526r7Hx8ZVWni0NhUOABlaTe-Ik_hMmUzWqYDdSzU,3383
|
|
10
|
+
htbp_sdk-0.1.0.dist-info/METADATA,sha256=29ZIdBn-MX6Psi35oXRqh_Kg3TUSNGeuh8n-gbnHuCM,2382
|
|
11
|
+
htbp_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
12
|
+
htbp_sdk-0.1.0.dist-info/RECORD,,
|