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 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"