htbp-sdk 0.1.0__tar.gz
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_sdk-0.1.0/.gitignore +10 -0
- htbp_sdk-0.1.0/PKG-INFO +87 -0
- htbp_sdk-0.1.0/README.md +73 -0
- htbp_sdk-0.1.0/examples/server.py +32 -0
- htbp_sdk-0.1.0/pyproject.toml +35 -0
- htbp_sdk-0.1.0/src/htbp/__init__.py +37 -0
- htbp_sdk-0.1.0/src/htbp/app.py +402 -0
- htbp_sdk-0.1.0/src/htbp/describe.py +55 -0
- htbp_sdk-0.1.0/src/htbp/errors.py +35 -0
- htbp_sdk-0.1.0/src/htbp/http.py +55 -0
- htbp_sdk-0.1.0/src/htbp/remote.py +188 -0
- htbp_sdk-0.1.0/src/htbp/schema.py +122 -0
- htbp_sdk-0.1.0/src/htbp/text_help.py +74 -0
- htbp_sdk-0.1.0/src/htbp/tree.py +112 -0
- htbp_sdk-0.1.0/tests/conftest.py +29 -0
- htbp_sdk-0.1.0/tests/test_call.py +176 -0
- htbp_sdk-0.1.0/tests/test_conformance.py +110 -0
- htbp_sdk-0.1.0/tests/test_help.py +119 -0
- htbp_sdk-0.1.0/tests/test_mount.py +113 -0
- htbp_sdk-0.1.0/tests/test_remote.py +148 -0
htbp_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
```
|
htbp_sdk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# htbp-sdk
|
|
2
|
+
|
|
3
|
+
HTBP (HTTP Tool Bridge Protocol) server SDK for Python. Expose functions as
|
|
4
|
+
self-describing HTTP tool endpoints, FastAPI-style. Pure ASGI — runs under
|
|
5
|
+
uvicorn/hypercorn and mounts inside FastAPI or Starlette.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pip install htbp-sdk # import name: htbp
|
|
11
|
+
pip install 'htbp-sdk[remote]' # + httpx, needed only for app.remote()
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quickstart
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from htbp import Htbp
|
|
18
|
+
|
|
19
|
+
app = Htbp(title="Math tools")
|
|
20
|
+
|
|
21
|
+
@app.tool(effect="read")
|
|
22
|
+
def add(a: int, b: int = 0) -> int:
|
|
23
|
+
"""Add two integers."""
|
|
24
|
+
return a + b
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
uvicorn main:app
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Input schemas are derived from type hints via pydantic (FastAPI-style); a
|
|
32
|
+
single parameter annotated with a `BaseModel` subclass uses that model as the
|
|
33
|
+
body schema directly. Async functions are awaited; sync functions run in a
|
|
34
|
+
thread. A `ToolContext`-annotated parameter receives request context.
|
|
35
|
+
|
|
36
|
+
Every node answers `GET {path}/~help` (JSON by default, RFC-0001 text DSL with
|
|
37
|
+
`Accept: text/plain`); tools are invoked with `POST {path}` and respond with
|
|
38
|
+
`{"resource", "result"}`.
|
|
39
|
+
|
|
40
|
+
## Cascading
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
docs = Htbp(title="Docs tools")
|
|
44
|
+
|
|
45
|
+
@docs.tool()
|
|
46
|
+
def search(q: str) -> dict:
|
|
47
|
+
return {"hits": [q]}
|
|
48
|
+
|
|
49
|
+
app.mount("/docs", docs) # local nesting
|
|
50
|
+
app.remote("/ext", "https://other.example.com/~help") # federate a remote HTBP server
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Remote nodes pass through `~help` for any sub-path and proxy calls; the inbound
|
|
54
|
+
`Authorization` header is never forwarded upstream.
|
|
55
|
+
|
|
56
|
+
Mount inside FastAPI:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
fastapi_app.mount("/tools", app)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Options
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
Htbp(
|
|
66
|
+
title="My tools",
|
|
67
|
+
description="...",
|
|
68
|
+
auth={"bearer_token": "..."}, # 401 + WWW-Authenticate without it
|
|
69
|
+
skill="# Markdown served at /~skill",
|
|
70
|
+
base_path="/htbp", # serve under a path prefix
|
|
71
|
+
cors=False, # CORS is on by default
|
|
72
|
+
)
|
|
73
|
+
```
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Runnable example: `uv run uvicorn examples.server:app --port 8788`.
|
|
2
|
+
|
|
3
|
+
Serves an HTBP tree on http://127.0.0.1:8788.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from htbp import Htbp
|
|
7
|
+
|
|
8
|
+
app = Htbp(
|
|
9
|
+
title="Example tools",
|
|
10
|
+
description="HTBP example server (Python SDK).",
|
|
11
|
+
skill="# Example tools\n\nCall `add` with `{\"a\": 1, \"b\": 2}`.\n",
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.tool(effect="read")
|
|
16
|
+
def add(a: int, b: int = 0) -> int:
|
|
17
|
+
"""Add two integers."""
|
|
18
|
+
return a + b
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
docs = Htbp(title="Docs tools", description="Documentation helpers.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@docs.tool(description="Search documents.")
|
|
25
|
+
def search(q: str) -> dict:
|
|
26
|
+
return {"hits": [f"result for {q}"]}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
app.mount("/docs", docs)
|
|
30
|
+
|
|
31
|
+
# Federate to another HTBP server (TS or Python) — cascading across processes:
|
|
32
|
+
# app.remote("/ext", "https://other.example.com/~help")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "htbp-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "HTBP (HTTP Tool Bridge Protocol) server SDK: expose functions as self-describing HTTP tool endpoints, FastAPI-style."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "TokenRoll AI" }]
|
|
13
|
+
keywords = ["htbp", "tool", "agent", "http", "sdk", "asgi"]
|
|
14
|
+
dependencies = ["pydantic>=2.7"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
remote = ["httpx>=0.27"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Repository = "https://github.com/TokenRollAI/http-tool-bridge"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=8",
|
|
25
|
+
"pytest-asyncio>=0.24",
|
|
26
|
+
"httpx>=0.27",
|
|
27
|
+
"uvicorn>=0.30",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/htbp"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
asyncio_mode = "auto"
|
|
35
|
+
testpaths = ["tests"]
|
|
@@ -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
|
+
]
|
|
@@ -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})
|