tileward 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.
- tileward/__init__.py +55 -0
- tileward/_http.py +365 -0
- tileward/_mcp.py +251 -0
- tileward/_version.py +1 -0
- tileward/auth.py +200 -0
- tileward/cli/__init__.py +1 -0
- tileward/cli/commands/__init__.py +1 -0
- tileward/cli/commands/account.py +95 -0
- tileward/cli/commands/auth.py +181 -0
- tileward/cli/commands/chat.py +174 -0
- tileward/cli/commands/config.py +103 -0
- tileward/cli/commands/context.py +239 -0
- tileward/cli/commands/docs.py +166 -0
- tileward/cli/commands/guard.py +107 -0
- tileward/cli/commands/keys.py +143 -0
- tileward/cli/commands/models.py +53 -0
- tileward/cli/main.py +263 -0
- tileward/cli/output.py +134 -0
- tileward/client.py +215 -0
- tileward/config.py +243 -0
- tileward/errors.py +129 -0
- tileward/py.typed +0 -0
- tileward/resources/__init__.py +1 -0
- tileward/resources/account.py +67 -0
- tileward/resources/chat.py +258 -0
- tileward/resources/context.py +273 -0
- tileward/resources/documents.py +180 -0
- tileward/resources/guard.py +91 -0
- tileward/resources/keys.py +102 -0
- tileward/resources/models.py +106 -0
- tileward-0.1.0.dist-info/METADATA +348 -0
- tileward-0.1.0.dist-info/RECORD +35 -0
- tileward-0.1.0.dist-info/WHEEL +4 -0
- tileward-0.1.0.dist-info/entry_points.txt +2 -0
- tileward-0.1.0.dist-info/licenses/LICENSE +21 -0
tileward/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Tileward: run large models on hardware you own, governed.
|
|
2
|
+
|
|
3
|
+
The library and the `twcli` command are the same package. Two lines to a first call:
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from tileward import Tileward
|
|
7
|
+
|
|
8
|
+
tw = Tileward(api_key="tw_live_...") # or set TILEWARD_API_KEY
|
|
9
|
+
print(tw.chat.say("Say hello in one sentence."))
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Four capabilities hang off the client:
|
|
13
|
+
|
|
14
|
+
`tw.models` / `tw.chat` Tileward Models — an OpenAI-compatible chat surface.
|
|
15
|
+
`tw.guard` Tileward Governance — allow/deny before the model writes a token.
|
|
16
|
+
`tw.context` Tileward Context — recall the slice of history a question needs.
|
|
17
|
+
`tw.documents` Tileward Documents — answers from your own files.
|
|
18
|
+
|
|
19
|
+
plus `tw.keys` and `tw.account`, which manage the account itself and need a console session from
|
|
20
|
+
`twcli auth login` rather than an API key.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from ._version import __version__
|
|
24
|
+
from .client import AsyncTileward, Tileward, openai_base_url
|
|
25
|
+
from .config import Config
|
|
26
|
+
from .errors import (
|
|
27
|
+
APIError,
|
|
28
|
+
AuthenticationError,
|
|
29
|
+
ConfigError,
|
|
30
|
+
ConnectionError_,
|
|
31
|
+
GuardRefusal,
|
|
32
|
+
InsufficientBalanceError,
|
|
33
|
+
NotFoundError,
|
|
34
|
+
RateLimitError,
|
|
35
|
+
ServerError,
|
|
36
|
+
TilewardError,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"Tileward",
|
|
41
|
+
"AsyncTileward",
|
|
42
|
+
"Config",
|
|
43
|
+
"openai_base_url",
|
|
44
|
+
"TilewardError",
|
|
45
|
+
"ConfigError",
|
|
46
|
+
"APIError",
|
|
47
|
+
"AuthenticationError",
|
|
48
|
+
"InsufficientBalanceError",
|
|
49
|
+
"NotFoundError",
|
|
50
|
+
"RateLimitError",
|
|
51
|
+
"ServerError",
|
|
52
|
+
"ConnectionError_",
|
|
53
|
+
"GuardRefusal",
|
|
54
|
+
"__version__",
|
|
55
|
+
]
|
tileward/_http.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""HTTP transport: auth, retries, error mapping, SSE.
|
|
2
|
+
|
|
3
|
+
Retries cover connection failures, 429 and 5xx only. Streaming responses are not
|
|
4
|
+
retried once bytes have arrived.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json as _json
|
|
10
|
+
import random
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import AsyncIterator, Iterator, Mapping
|
|
13
|
+
from typing import Any, Dict, Optional, Union
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from . import errors
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
|
|
20
|
+
DEFAULT_TIMEOUT = 60.0
|
|
21
|
+
# Generation is slow by nature: a long completion legitimately takes minutes, and a read timeout
|
|
22
|
+
# that fires mid-answer bills for tokens the caller never sees.
|
|
23
|
+
STREAM_TIMEOUT = 600.0
|
|
24
|
+
DEFAULT_MAX_RETRIES = 2
|
|
25
|
+
RETRY_STATUS = frozenset({408, 409, 429, 500, 502, 503, 504})
|
|
26
|
+
|
|
27
|
+
USER_AGENT = f"tileward-python/{__version__}"
|
|
28
|
+
|
|
29
|
+
AuthMode = str # "key" | "session" | "none"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _user_agent(suffix: Optional[str] = None) -> str:
|
|
33
|
+
return f"{USER_AGENT} ({suffix})" if suffix else USER_AGENT
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _decode(response: httpx.Response) -> Any:
|
|
37
|
+
ctype = response.headers.get("content-type", "")
|
|
38
|
+
if "json" in ctype:
|
|
39
|
+
try:
|
|
40
|
+
return response.json()
|
|
41
|
+
except ValueError:
|
|
42
|
+
pass
|
|
43
|
+
text = response.text
|
|
44
|
+
try:
|
|
45
|
+
return _json.loads(text)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return text
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _request_id(response: httpx.Response) -> Optional[str]:
|
|
51
|
+
for header in ("x-request-id", "x-tileward-request-id", "cf-ray"):
|
|
52
|
+
value = response.headers.get(header)
|
|
53
|
+
if value:
|
|
54
|
+
return value
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _retry_after(response: httpx.Response) -> Optional[float]:
|
|
59
|
+
raw = response.headers.get("retry-after")
|
|
60
|
+
if not raw:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return max(0.0, float(raw))
|
|
64
|
+
except ValueError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _backoff(attempt: int, retry_after: Optional[float]) -> float:
|
|
69
|
+
"""Exponential with full jitter, floored by any Retry-After the server sent.
|
|
70
|
+
|
|
71
|
+
Full jitter rather than a fixed doubling: a fleet of clients that all retry at exactly 1s, 2s,
|
|
72
|
+
4s re-creates the burst that caused the 429 in the first place.
|
|
73
|
+
"""
|
|
74
|
+
if retry_after is not None:
|
|
75
|
+
return min(retry_after, 60.0)
|
|
76
|
+
return min(0.5 * (2**attempt), 8.0) * (0.5 + random.random() / 2)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _iter_sse(lines: Iterator[str]) -> Iterator[Dict[str, Any]]:
|
|
80
|
+
"""Yield decoded `data:` payloads from an SSE stream, stopping at `[DONE]`.
|
|
81
|
+
|
|
82
|
+
The sentinel is not JSON, so a caller that json.loads every data line crashes on the last one.
|
|
83
|
+
"""
|
|
84
|
+
for raw in lines:
|
|
85
|
+
line = raw.strip()
|
|
86
|
+
if not line or line.startswith(":") or not line.startswith("data:"):
|
|
87
|
+
continue
|
|
88
|
+
payload = line[len("data:") :].strip()
|
|
89
|
+
if payload == "[DONE]":
|
|
90
|
+
return
|
|
91
|
+
try:
|
|
92
|
+
yield _json.loads(payload)
|
|
93
|
+
except ValueError:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class _Base:
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
base_url: str,
|
|
102
|
+
console_url: Optional[str] = None,
|
|
103
|
+
api_key: Optional[str] = None,
|
|
104
|
+
session_token: Optional[str] = None,
|
|
105
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
106
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
107
|
+
user_agent_suffix: Optional[str] = None,
|
|
108
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
self.base_url = base_url.rstrip("/")
|
|
111
|
+
# TWO HOSTS, AND THEY DO NOT OVERLAP. `api.tileward.com` serves `/v1` only and answers
|
|
112
|
+
# anything else with 404 `wrong_host`. The session surface — `/auth/*` and every
|
|
113
|
+
# `/api/*` — lives on the console host. Routing by auth mode is what keeps that
|
|
114
|
+
# invisible to callers.
|
|
115
|
+
self.console_url = (console_url or base_url).rstrip("/")
|
|
116
|
+
self.api_key = api_key
|
|
117
|
+
self.session_token = session_token
|
|
118
|
+
self.timeout = timeout
|
|
119
|
+
self.max_retries = max(0, int(max_retries))
|
|
120
|
+
self.default_headers = dict(default_headers or {})
|
|
121
|
+
self.user_agent = _user_agent(user_agent_suffix)
|
|
122
|
+
|
|
123
|
+
def _url(self, path: str, auth: AuthMode = "key", host: Optional[str] = None) -> str:
|
|
124
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
125
|
+
return path
|
|
126
|
+
# `session` always means the console. `none` is only used by the device-code flow, which
|
|
127
|
+
# is also console-only, so it routes there too — a caller wanting the api host with no
|
|
128
|
+
# credential passes host="api" explicitly.
|
|
129
|
+
which = host or ("console" if auth in ("session", "none") else "api")
|
|
130
|
+
root = self.console_url if which == "console" else self.base_url
|
|
131
|
+
return f"{root}/{path.lstrip('/')}"
|
|
132
|
+
|
|
133
|
+
def _headers(self, auth: AuthMode, extra: Optional[Mapping[str, str]]) -> Dict[str, str]:
|
|
134
|
+
headers: Dict[str, str] = {
|
|
135
|
+
"User-Agent": self.user_agent,
|
|
136
|
+
"Accept": "application/json",
|
|
137
|
+
}
|
|
138
|
+
headers.update(self.default_headers)
|
|
139
|
+
if auth == "key":
|
|
140
|
+
if not self.api_key:
|
|
141
|
+
raise errors.ConfigError(
|
|
142
|
+
"No API key. Set TILEWARD_API_KEY, pass api_key=..., or run "
|
|
143
|
+
"`twcli auth login` and mint one with `twcli keys create`."
|
|
144
|
+
)
|
|
145
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
146
|
+
elif auth == "session":
|
|
147
|
+
if not self.session_token:
|
|
148
|
+
raise errors.ConfigError(
|
|
149
|
+
"This needs a signed-in console session, not an API key. "
|
|
150
|
+
"Run `twcli auth login`."
|
|
151
|
+
)
|
|
152
|
+
# The account surface authenticates on the session COOKIE, not a bearer header —
|
|
153
|
+
# `_session_sub` in the gateway reads `request.cookies`. Sending it as a bearer token
|
|
154
|
+
# authenticates nothing and returns a confusing 401.
|
|
155
|
+
headers["Cookie"] = f"tw_session={self.session_token}"
|
|
156
|
+
if extra:
|
|
157
|
+
headers.update({k: v for k, v in extra.items() if v is not None})
|
|
158
|
+
return headers
|
|
159
|
+
|
|
160
|
+
def _raise(self, response: httpx.Response) -> None:
|
|
161
|
+
raise errors.from_response(response.status_code, _decode(response), _request_id(response))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class Transport(_Base):
|
|
165
|
+
"""Synchronous transport."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, *args: Any, client: Optional[httpx.Client] = None, **kwargs: Any) -> None:
|
|
168
|
+
super().__init__(*args, **kwargs)
|
|
169
|
+
self._client = client
|
|
170
|
+
self._owns_client = client is None
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def client(self) -> httpx.Client:
|
|
174
|
+
if self._client is None:
|
|
175
|
+
self._client = httpx.Client(timeout=self.timeout, follow_redirects=True)
|
|
176
|
+
return self._client
|
|
177
|
+
|
|
178
|
+
def close(self) -> None:
|
|
179
|
+
if self._client is not None and self._owns_client:
|
|
180
|
+
self._client.close()
|
|
181
|
+
self._client = None
|
|
182
|
+
|
|
183
|
+
def request(
|
|
184
|
+
self,
|
|
185
|
+
method: str,
|
|
186
|
+
path: str,
|
|
187
|
+
*,
|
|
188
|
+
json: Any = None,
|
|
189
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
190
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
191
|
+
auth: AuthMode = "key",
|
|
192
|
+
timeout: Optional[float] = None,
|
|
193
|
+
retries: Optional[int] = None,
|
|
194
|
+
raw: bool = False,
|
|
195
|
+
host: Optional[str] = None,
|
|
196
|
+
) -> Any:
|
|
197
|
+
url = self._url(path, auth, host)
|
|
198
|
+
hdrs = self._headers(auth, headers)
|
|
199
|
+
budget = self.max_retries if retries is None else max(0, retries)
|
|
200
|
+
attempt = 0
|
|
201
|
+
while True:
|
|
202
|
+
try:
|
|
203
|
+
response = self.client.request(
|
|
204
|
+
method,
|
|
205
|
+
url,
|
|
206
|
+
json=json,
|
|
207
|
+
params=_clean_params(params),
|
|
208
|
+
headers=hdrs,
|
|
209
|
+
timeout=timeout or self.timeout,
|
|
210
|
+
)
|
|
211
|
+
except httpx.TimeoutException as exc:
|
|
212
|
+
if attempt >= budget:
|
|
213
|
+
raise errors.ConnectionError_(f"{method} {url} timed out: {exc}") from exc
|
|
214
|
+
except httpx.HTTPError as exc:
|
|
215
|
+
if attempt >= budget:
|
|
216
|
+
raise errors.ConnectionError_(f"{method} {url} failed: {exc}") from exc
|
|
217
|
+
else:
|
|
218
|
+
if response.status_code in RETRY_STATUS and attempt < budget:
|
|
219
|
+
time.sleep(_backoff(attempt, _retry_after(response)))
|
|
220
|
+
attempt += 1
|
|
221
|
+
continue
|
|
222
|
+
if response.status_code >= 400:
|
|
223
|
+
self._raise(response)
|
|
224
|
+
return response if raw else _decode(response)
|
|
225
|
+
time.sleep(_backoff(attempt, None))
|
|
226
|
+
attempt += 1
|
|
227
|
+
|
|
228
|
+
def stream_sse(
|
|
229
|
+
self,
|
|
230
|
+
method: str,
|
|
231
|
+
path: str,
|
|
232
|
+
*,
|
|
233
|
+
json: Any = None,
|
|
234
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
235
|
+
auth: AuthMode = "key",
|
|
236
|
+
timeout: Optional[float] = None,
|
|
237
|
+
host: Optional[str] = None,
|
|
238
|
+
) -> Iterator[Dict[str, Any]]:
|
|
239
|
+
url = self._url(path, auth, host)
|
|
240
|
+
hdrs = self._headers(auth, headers)
|
|
241
|
+
hdrs["Accept"] = "text/event-stream"
|
|
242
|
+
try:
|
|
243
|
+
with self.client.stream(
|
|
244
|
+
method, url, json=json, headers=hdrs, timeout=timeout or STREAM_TIMEOUT
|
|
245
|
+
) as response:
|
|
246
|
+
if response.status_code >= 400:
|
|
247
|
+
response.read()
|
|
248
|
+
self._raise(response)
|
|
249
|
+
yield from _iter_sse(response.iter_lines())
|
|
250
|
+
except httpx.HTTPError as exc:
|
|
251
|
+
raise errors.ConnectionError_(f"{method} {url} stream failed: {exc}") from exc
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class AsyncTransport(_Base):
|
|
255
|
+
"""Asynchronous transport. Same policy, same errors."""
|
|
256
|
+
|
|
257
|
+
def __init__(
|
|
258
|
+
self, *args: Any, client: Optional[httpx.AsyncClient] = None, **kwargs: Any
|
|
259
|
+
) -> None:
|
|
260
|
+
super().__init__(*args, **kwargs)
|
|
261
|
+
self._client = client
|
|
262
|
+
self._owns_client = client is None
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def client(self) -> httpx.AsyncClient:
|
|
266
|
+
if self._client is None:
|
|
267
|
+
self._client = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True)
|
|
268
|
+
return self._client
|
|
269
|
+
|
|
270
|
+
async def aclose(self) -> None:
|
|
271
|
+
if self._client is not None and self._owns_client:
|
|
272
|
+
await self._client.aclose()
|
|
273
|
+
self._client = None
|
|
274
|
+
|
|
275
|
+
async def request(
|
|
276
|
+
self,
|
|
277
|
+
method: str,
|
|
278
|
+
path: str,
|
|
279
|
+
*,
|
|
280
|
+
json: Any = None,
|
|
281
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
282
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
283
|
+
auth: AuthMode = "key",
|
|
284
|
+
timeout: Optional[float] = None,
|
|
285
|
+
retries: Optional[int] = None,
|
|
286
|
+
raw: bool = False,
|
|
287
|
+
host: Optional[str] = None,
|
|
288
|
+
) -> Any:
|
|
289
|
+
import asyncio
|
|
290
|
+
|
|
291
|
+
url = self._url(path, auth, host)
|
|
292
|
+
hdrs = self._headers(auth, headers)
|
|
293
|
+
budget = self.max_retries if retries is None else max(0, retries)
|
|
294
|
+
attempt = 0
|
|
295
|
+
while True:
|
|
296
|
+
try:
|
|
297
|
+
response = await self.client.request(
|
|
298
|
+
method,
|
|
299
|
+
url,
|
|
300
|
+
json=json,
|
|
301
|
+
params=_clean_params(params),
|
|
302
|
+
headers=hdrs,
|
|
303
|
+
timeout=timeout or self.timeout,
|
|
304
|
+
)
|
|
305
|
+
except httpx.TimeoutException as exc:
|
|
306
|
+
if attempt >= budget:
|
|
307
|
+
raise errors.ConnectionError_(f"{method} {url} timed out: {exc}") from exc
|
|
308
|
+
except httpx.HTTPError as exc:
|
|
309
|
+
if attempt >= budget:
|
|
310
|
+
raise errors.ConnectionError_(f"{method} {url} failed: {exc}") from exc
|
|
311
|
+
else:
|
|
312
|
+
if response.status_code in RETRY_STATUS and attempt < budget:
|
|
313
|
+
await asyncio.sleep(_backoff(attempt, _retry_after(response)))
|
|
314
|
+
attempt += 1
|
|
315
|
+
continue
|
|
316
|
+
if response.status_code >= 400:
|
|
317
|
+
self._raise(response)
|
|
318
|
+
return response if raw else _decode(response)
|
|
319
|
+
await asyncio.sleep(_backoff(attempt, None))
|
|
320
|
+
attempt += 1
|
|
321
|
+
|
|
322
|
+
async def stream_sse(
|
|
323
|
+
self,
|
|
324
|
+
method: str,
|
|
325
|
+
path: str,
|
|
326
|
+
*,
|
|
327
|
+
json: Any = None,
|
|
328
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
329
|
+
auth: AuthMode = "key",
|
|
330
|
+
timeout: Optional[float] = None,
|
|
331
|
+
host: Optional[str] = None,
|
|
332
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
333
|
+
url = self._url(path, auth, host)
|
|
334
|
+
hdrs = self._headers(auth, headers)
|
|
335
|
+
hdrs["Accept"] = "text/event-stream"
|
|
336
|
+
try:
|
|
337
|
+
async with self.client.stream(
|
|
338
|
+
method, url, json=json, headers=hdrs, timeout=timeout or STREAM_TIMEOUT
|
|
339
|
+
) as response:
|
|
340
|
+
if response.status_code >= 400:
|
|
341
|
+
await response.aread()
|
|
342
|
+
self._raise(response)
|
|
343
|
+
async for raw_line in response.aiter_lines():
|
|
344
|
+
line = raw_line.strip()
|
|
345
|
+
if not line.startswith("data:"):
|
|
346
|
+
continue
|
|
347
|
+
payload = line[len("data:") :].strip()
|
|
348
|
+
if payload == "[DONE]":
|
|
349
|
+
return
|
|
350
|
+
try:
|
|
351
|
+
yield _json.loads(payload)
|
|
352
|
+
except ValueError:
|
|
353
|
+
continue
|
|
354
|
+
except httpx.HTTPError as exc:
|
|
355
|
+
raise errors.ConnectionError_(f"{method} {url} stream failed: {exc}") from exc
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _clean_params(params: Optional[Mapping[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
359
|
+
"""Drop None values so an unset optional does not become the literal string 'None'."""
|
|
360
|
+
if not params:
|
|
361
|
+
return None
|
|
362
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
Streamable = Union[Iterator[Dict[str, Any]], AsyncIterator[Dict[str, Any]]]
|
tileward/_mcp.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Minimal MCP client for Tileward Context.
|
|
2
|
+
|
|
3
|
+
The transport is stateless streamable-HTTP, so a `tools/call` needs no initialize
|
|
4
|
+
handshake. The conversation header goes out under both spellings; one deployed
|
|
5
|
+
generation reads only the older name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json as _json
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from . import errors
|
|
17
|
+
|
|
18
|
+
JSONRPC_VERSION = "2.0"
|
|
19
|
+
# Streamable HTTP may answer with either a JSON body or an SSE stream; the spec allows both and
|
|
20
|
+
# the server picks. Asking for both is what keeps the client working across that choice.
|
|
21
|
+
MCP_ACCEPT = "application/json, text/event-stream"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_payload(tool: str, arguments: Mapping[str, Any], request_id: int = 1) -> Dict[str, Any]:
|
|
25
|
+
return {
|
|
26
|
+
"jsonrpc": JSONRPC_VERSION,
|
|
27
|
+
"id": request_id,
|
|
28
|
+
"method": "tools/call",
|
|
29
|
+
"params": {
|
|
30
|
+
"name": tool,
|
|
31
|
+
"arguments": {k: v for k, v in arguments.items() if v is not None},
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def conversation_headers(conversation: Optional[str]) -> Dict[str, str]:
|
|
37
|
+
if not conversation:
|
|
38
|
+
return {}
|
|
39
|
+
value = str(conversation).strip()
|
|
40
|
+
if not value:
|
|
41
|
+
return {}
|
|
42
|
+
return {"X-Tileward-Conversation": value, "X-Twinkle-Conversation": value}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_body(status: int, content_type: str, text: str) -> Dict[str, Any]:
|
|
46
|
+
"""Decode a streamable-HTTP response into the JSON-RPC envelope."""
|
|
47
|
+
if "text/event-stream" in (content_type or ""):
|
|
48
|
+
envelope: Optional[Dict[str, Any]] = None
|
|
49
|
+
for raw in text.splitlines():
|
|
50
|
+
line = raw.strip()
|
|
51
|
+
if not line.startswith("data:"):
|
|
52
|
+
continue
|
|
53
|
+
payload = line[len("data:") :].strip()
|
|
54
|
+
if not payload or payload == "[DONE]":
|
|
55
|
+
continue
|
|
56
|
+
try:
|
|
57
|
+
candidate = _json.loads(payload)
|
|
58
|
+
except ValueError:
|
|
59
|
+
continue
|
|
60
|
+
if isinstance(candidate, dict) and ("result" in candidate or "error" in candidate):
|
|
61
|
+
envelope = candidate
|
|
62
|
+
if envelope is None:
|
|
63
|
+
raise errors.APIError(
|
|
64
|
+
"Context returned an event stream with no JSON-RPC response in it.",
|
|
65
|
+
status=status,
|
|
66
|
+
body={"raw": text[:500]},
|
|
67
|
+
)
|
|
68
|
+
return envelope
|
|
69
|
+
try:
|
|
70
|
+
decoded = _json.loads(text)
|
|
71
|
+
except ValueError as exc:
|
|
72
|
+
raise errors.APIError(
|
|
73
|
+
f"Context returned a body that is not JSON: {text[:200]}", status=status
|
|
74
|
+
) from exc
|
|
75
|
+
if not isinstance(decoded, dict):
|
|
76
|
+
raise errors.APIError("Context returned a JSON value that is not an object.", status=status)
|
|
77
|
+
return decoded
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def unwrap(envelope: Mapping[str, Any]) -> Any:
|
|
81
|
+
"""Pull a tool's return value out of the JSON-RPC + MCP content wrapping.
|
|
82
|
+
|
|
83
|
+
Three failure shapes have to be told apart, and they arrive at three different depths:
|
|
84
|
+
* a JSON-RPC `error` — the method or the arguments were wrong;
|
|
85
|
+
* `result.isError` — the tool ran and refused (a bad conversation id, a missing document);
|
|
86
|
+
* a result with no content — nothing to unwrap, and returning `{}` would look like success.
|
|
87
|
+
"""
|
|
88
|
+
if "error" in envelope:
|
|
89
|
+
err = envelope.get("error") or {}
|
|
90
|
+
message = str(err.get("message") or "Context returned a JSON-RPC error.")
|
|
91
|
+
raise errors.APIError(message, code=str(err.get("code") or ""), body=dict(envelope))
|
|
92
|
+
|
|
93
|
+
result = envelope.get("result")
|
|
94
|
+
if not isinstance(result, dict):
|
|
95
|
+
raise errors.APIError("Context returned no result.", body=dict(envelope))
|
|
96
|
+
|
|
97
|
+
content = result.get("content") or []
|
|
98
|
+
text = ""
|
|
99
|
+
if isinstance(content, list) and content:
|
|
100
|
+
first = content[0]
|
|
101
|
+
if isinstance(first, dict):
|
|
102
|
+
text = str(first.get("text") or "")
|
|
103
|
+
|
|
104
|
+
if result.get("isError"):
|
|
105
|
+
raise errors.APIError(text or "The Context tool reported an error.", body=dict(envelope))
|
|
106
|
+
|
|
107
|
+
# A tool that returns a dict is serialised as JSON inside a text block; one that returns a
|
|
108
|
+
# string is that string. Both are legitimate, so a failed parse is data, not an error.
|
|
109
|
+
if not text:
|
|
110
|
+
structured = result.get("structuredContent")
|
|
111
|
+
return structured if structured is not None else {}
|
|
112
|
+
try:
|
|
113
|
+
return _json.loads(text)
|
|
114
|
+
except ValueError:
|
|
115
|
+
return text
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ContextTransport:
|
|
119
|
+
"""Synchronous MCP-over-HTTP calls against the Context endpoint."""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
url: str,
|
|
125
|
+
api_key: Optional[str],
|
|
126
|
+
timeout: float = 60.0,
|
|
127
|
+
user_agent: str = "tileward-python",
|
|
128
|
+
client: Optional[httpx.Client] = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
self.url = url.rstrip("/")
|
|
131
|
+
self.api_key = api_key
|
|
132
|
+
self.timeout = timeout
|
|
133
|
+
self.user_agent = user_agent
|
|
134
|
+
self._client = client
|
|
135
|
+
self._owns_client = client is None
|
|
136
|
+
self._counter = 0
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def client(self) -> httpx.Client:
|
|
140
|
+
if self._client is None:
|
|
141
|
+
self._client = httpx.Client(timeout=self.timeout, follow_redirects=True)
|
|
142
|
+
return self._client
|
|
143
|
+
|
|
144
|
+
def close(self) -> None:
|
|
145
|
+
if self._client is not None and self._owns_client:
|
|
146
|
+
self._client.close()
|
|
147
|
+
self._client = None
|
|
148
|
+
|
|
149
|
+
def _headers(self, conversation: Optional[str]) -> Dict[str, str]:
|
|
150
|
+
if not self.api_key:
|
|
151
|
+
raise errors.ConfigError(
|
|
152
|
+
"Tileward Context needs an API key. Set TILEWARD_API_KEY or pass api_key=..."
|
|
153
|
+
)
|
|
154
|
+
headers = {
|
|
155
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
"Accept": MCP_ACCEPT,
|
|
158
|
+
"User-Agent": self.user_agent,
|
|
159
|
+
}
|
|
160
|
+
headers.update(conversation_headers(conversation))
|
|
161
|
+
return headers
|
|
162
|
+
|
|
163
|
+
def call(
|
|
164
|
+
self,
|
|
165
|
+
tool: str,
|
|
166
|
+
arguments: Optional[Mapping[str, Any]] = None,
|
|
167
|
+
*,
|
|
168
|
+
conversation: Optional[str] = None,
|
|
169
|
+
timeout: Optional[float] = None,
|
|
170
|
+
) -> Any:
|
|
171
|
+
self._counter += 1
|
|
172
|
+
payload = build_payload(tool, arguments or {}, self._counter)
|
|
173
|
+
try:
|
|
174
|
+
response = self.client.post(
|
|
175
|
+
self.url,
|
|
176
|
+
json=payload,
|
|
177
|
+
headers=self._headers(conversation),
|
|
178
|
+
timeout=timeout or self.timeout,
|
|
179
|
+
)
|
|
180
|
+
except httpx.HTTPError as exc:
|
|
181
|
+
raise errors.ConnectionError_(f"Context call to {self.url} failed: {exc}") from exc
|
|
182
|
+
if response.status_code >= 400:
|
|
183
|
+
# A 401 here is its own thing: the Context endpoint takes a Tileward API key, and the
|
|
184
|
+
# error it returns points OAuth clients at a different path. Surface it verbatim.
|
|
185
|
+
raise errors.from_response(
|
|
186
|
+
response.status_code,
|
|
187
|
+
_safe_json(response.text),
|
|
188
|
+
response.headers.get("x-request-id"),
|
|
189
|
+
)
|
|
190
|
+
envelope = parse_body(
|
|
191
|
+
response.status_code, response.headers.get("content-type", ""), response.text
|
|
192
|
+
)
|
|
193
|
+
return unwrap(envelope)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class AsyncContextTransport(ContextTransport):
|
|
197
|
+
"""Asynchronous twin. Shares every decoding rule above."""
|
|
198
|
+
|
|
199
|
+
def __init__(self, *args: Any, client: Optional[httpx.AsyncClient] = None, **kwargs: Any):
|
|
200
|
+
kwargs.pop("client", None)
|
|
201
|
+
super().__init__(*args, **kwargs)
|
|
202
|
+
self._aclient = client
|
|
203
|
+
self._owns_aclient = client is None
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def aclient(self) -> httpx.AsyncClient:
|
|
207
|
+
if self._aclient is None:
|
|
208
|
+
self._aclient = httpx.AsyncClient(timeout=self.timeout, follow_redirects=True)
|
|
209
|
+
return self._aclient
|
|
210
|
+
|
|
211
|
+
async def aclose(self) -> None:
|
|
212
|
+
if self._aclient is not None and self._owns_aclient:
|
|
213
|
+
await self._aclient.aclose()
|
|
214
|
+
self._aclient = None
|
|
215
|
+
|
|
216
|
+
async def acall(
|
|
217
|
+
self,
|
|
218
|
+
tool: str,
|
|
219
|
+
arguments: Optional[Mapping[str, Any]] = None,
|
|
220
|
+
*,
|
|
221
|
+
conversation: Optional[str] = None,
|
|
222
|
+
timeout: Optional[float] = None,
|
|
223
|
+
) -> Any:
|
|
224
|
+
self._counter += 1
|
|
225
|
+
payload = build_payload(tool, arguments or {}, self._counter)
|
|
226
|
+
try:
|
|
227
|
+
response = await self.aclient.post(
|
|
228
|
+
self.url,
|
|
229
|
+
json=payload,
|
|
230
|
+
headers=self._headers(conversation),
|
|
231
|
+
timeout=timeout or self.timeout,
|
|
232
|
+
)
|
|
233
|
+
except httpx.HTTPError as exc:
|
|
234
|
+
raise errors.ConnectionError_(f"Context call to {self.url} failed: {exc}") from exc
|
|
235
|
+
if response.status_code >= 400:
|
|
236
|
+
raise errors.from_response(
|
|
237
|
+
response.status_code,
|
|
238
|
+
_safe_json(response.text),
|
|
239
|
+
response.headers.get("x-request-id"),
|
|
240
|
+
)
|
|
241
|
+
envelope = parse_body(
|
|
242
|
+
response.status_code, response.headers.get("content-type", ""), response.text
|
|
243
|
+
)
|
|
244
|
+
return unwrap(envelope)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _safe_json(text: str) -> Any:
|
|
248
|
+
try:
|
|
249
|
+
return _json.loads(text)
|
|
250
|
+
except ValueError:
|
|
251
|
+
return text
|
tileward/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|