clone-sdk 0.2.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.
@@ -0,0 +1,23 @@
1
+ node_modules/
2
+ dist/
3
+ artifacts/
4
+ test-results/
5
+ playwright-report/
6
+ release-manifest.json
7
+ *.tgz
8
+ *.log
9
+ .env
10
+ .env.*
11
+ !.env.example
12
+ .DS_Store
13
+
14
+ # Local audit and agent output must never enter the public repository.
15
+ .gstack/
16
+ output/
17
+ coverage/
18
+ python/.venv/
19
+ python/dist/
20
+ python/.pytest_cache/
21
+ python/.ruff_cache/
22
+ __pycache__/
23
+ *.pyc
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clone contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: clone-sdk
3
+ Version: 0.2.0
4
+ Summary: Server-side Python client for Clone prompt predictions.
5
+ Project-URL: Homepage, https://clone.is/clone-sdk
6
+ Project-URL: Repository, https://github.com/cloneisyou/clone-sdk
7
+ Project-URL: Documentation, https://clone.is/docs
8
+ Project-URL: Issues, https://github.com/cloneisyou/clone-sdk/issues
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: httpx<1,>=0.28.1
15
+ Requires-Dist: pydantic<3,>=2.7
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Clone Python SDK
19
+
20
+ Server-side synchronous and asynchronous clients for Clone predictions, usage,
21
+ observation events, cancellation and optional user-approved Clone connections.
22
+ Composer UI stays in the JavaScript SDK. This client never submits a user message.
23
+
24
+ Python 3.11+. The candidate distribution is `clone-sdk`; import `clone_sdk`.
25
+ Until the first PyPI publication, install the verified wheel from the release.
26
+
27
+ ```sh
28
+ pip install ./clone_sdk-0.2.0-py3-none-any.whl
29
+ ```
30
+
31
+ ```python
32
+ import os
33
+ from clone_sdk import CloneClient
34
+
35
+ with CloneClient(os.environ["CLONE_APP_KEY"]) as client:
36
+ result = client.predict(
37
+ "subject-from-your-authenticated-session",
38
+ {
39
+ "request_id": "prediction-001",
40
+ "session_id": "conversation-001",
41
+ "context_revision": "1",
42
+ "mode": "next_prompt",
43
+ "draft": {"text": "", "revision": 0},
44
+ "messages": [{"role": "user", "content": "Draft a product announcement.", "origin": "human"}],
45
+ "language": "en",
46
+ },
47
+ )
48
+ print(result.status)
49
+ ```
50
+
51
+ For FastAPI or other async servers, use `async with AsyncCloneClient(...)` and
52
+ `await client.predict(...)`. Reuse a client for the application's lifetime and
53
+ close it during shutdown. The async client enforces a total 15-second deadline,
54
+ including body reads. The sync client uses 15-second per-I/O timeouts and checks
55
+ the elapsed budget between response chunks. Both bound concurrency to 16 by
56
+ default, reject saturation without queuing, limit response bodies to 1 MiB, and
57
+ never automatically retry potentially billable work. Override `timeout` up to
58
+ 30 seconds and `max_concurrent_requests` up to 1,000 after measuring your load.
59
+
60
+ Derive `user_id` from your server session; the client overwrites an untrusted
61
+ body's `user_id`. Keep the app key exclusively on your backend. HTTPS is required
62
+ except loopback development. Redirects are not followed. Client errors expose
63
+ only a safe `code`, HTTP `status` and numeric `retry_after`, never response text.
64
+
65
+ Keep the same request ID and exact body when reconciling an unknown result.
66
+ Hide failed predictions and keep your editor and manual send path independent.
67
+ Response identity is checked; the host still owns expiry, rendering, Tab/Undo,
68
+ session changes and successful-send acknowledgments.
69
+
70
+ Optional personalization uses `started, flow = client.connect(user_id, redirect_uri)`
71
+ to create a PKCE flow (or `started, flow = await client.connect(...)` for async).
72
+ Store the returned flow in a server session, redirect the user to
73
+ `started.authorize_url`, then call `exchange(flow, callback, authenticated_user_id)`.
74
+ State, request and subject must match. The flow contains a secret verifier and
75
+ must not be logged or serialized to browser storage. Basic predictions need no
76
+ Clone end-user account.
77
+
78
+ ```python
79
+ client.record_event(
80
+ subject,
81
+ {
82
+ "event_id": "event-001",
83
+ "request_id": "prediction-001",
84
+ "kind": "presented",
85
+ },
86
+ )
87
+ usage = client.usage()
88
+ client.cancel(subject, "prediction-001")
89
+ ```
90
+
91
+ Only report `submitted` after your host accepts the send. Reuse an event ID on
92
+ delivery retries. Events contain no prompt text and do not change billing.
93
+ Read the [public API](https://clone.is/docs/api-reference),
94
+ [billing](https://clone.is/docs/billing) and
95
+ [reliability](https://clone.is/docs/reliability) contracts before production use.
96
+
97
+ Development: `uv sync`, `uv run pytest`, `uv run ruff check .`, `uv build` and
98
+ `uv run twine check dist/*`. All ordinary tests use local or mocked transports;
99
+ they do not establish model quality or customer adoption.
@@ -0,0 +1,82 @@
1
+ # Clone Python SDK
2
+
3
+ Server-side synchronous and asynchronous clients for Clone predictions, usage,
4
+ observation events, cancellation and optional user-approved Clone connections.
5
+ Composer UI stays in the JavaScript SDK. This client never submits a user message.
6
+
7
+ Python 3.11+. The candidate distribution is `clone-sdk`; import `clone_sdk`.
8
+ Until the first PyPI publication, install the verified wheel from the release.
9
+
10
+ ```sh
11
+ pip install ./clone_sdk-0.2.0-py3-none-any.whl
12
+ ```
13
+
14
+ ```python
15
+ import os
16
+ from clone_sdk import CloneClient
17
+
18
+ with CloneClient(os.environ["CLONE_APP_KEY"]) as client:
19
+ result = client.predict(
20
+ "subject-from-your-authenticated-session",
21
+ {
22
+ "request_id": "prediction-001",
23
+ "session_id": "conversation-001",
24
+ "context_revision": "1",
25
+ "mode": "next_prompt",
26
+ "draft": {"text": "", "revision": 0},
27
+ "messages": [{"role": "user", "content": "Draft a product announcement.", "origin": "human"}],
28
+ "language": "en",
29
+ },
30
+ )
31
+ print(result.status)
32
+ ```
33
+
34
+ For FastAPI or other async servers, use `async with AsyncCloneClient(...)` and
35
+ `await client.predict(...)`. Reuse a client for the application's lifetime and
36
+ close it during shutdown. The async client enforces a total 15-second deadline,
37
+ including body reads. The sync client uses 15-second per-I/O timeouts and checks
38
+ the elapsed budget between response chunks. Both bound concurrency to 16 by
39
+ default, reject saturation without queuing, limit response bodies to 1 MiB, and
40
+ never automatically retry potentially billable work. Override `timeout` up to
41
+ 30 seconds and `max_concurrent_requests` up to 1,000 after measuring your load.
42
+
43
+ Derive `user_id` from your server session; the client overwrites an untrusted
44
+ body's `user_id`. Keep the app key exclusively on your backend. HTTPS is required
45
+ except loopback development. Redirects are not followed. Client errors expose
46
+ only a safe `code`, HTTP `status` and numeric `retry_after`, never response text.
47
+
48
+ Keep the same request ID and exact body when reconciling an unknown result.
49
+ Hide failed predictions and keep your editor and manual send path independent.
50
+ Response identity is checked; the host still owns expiry, rendering, Tab/Undo,
51
+ session changes and successful-send acknowledgments.
52
+
53
+ Optional personalization uses `started, flow = client.connect(user_id, redirect_uri)`
54
+ to create a PKCE flow (or `started, flow = await client.connect(...)` for async).
55
+ Store the returned flow in a server session, redirect the user to
56
+ `started.authorize_url`, then call `exchange(flow, callback, authenticated_user_id)`.
57
+ State, request and subject must match. The flow contains a secret verifier and
58
+ must not be logged or serialized to browser storage. Basic predictions need no
59
+ Clone end-user account.
60
+
61
+ ```python
62
+ client.record_event(
63
+ subject,
64
+ {
65
+ "event_id": "event-001",
66
+ "request_id": "prediction-001",
67
+ "kind": "presented",
68
+ },
69
+ )
70
+ usage = client.usage()
71
+ client.cancel(subject, "prediction-001")
72
+ ```
73
+
74
+ Only report `submitted` after your host accepts the send. Reuse an event ID on
75
+ delivery retries. Events contain no prompt text and do not change billing.
76
+ Read the [public API](https://clone.is/docs/api-reference),
77
+ [billing](https://clone.is/docs/billing) and
78
+ [reliability](https://clone.is/docs/reliability) contracts before production use.
79
+
80
+ Development: `uv sync`, `uv run pytest`, `uv run ruff check .`, `uv build` and
81
+ `uv run twine check dist/*`. All ordinary tests use local or mocked transports;
82
+ they do not establish model quality or customer adoption.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.31.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "clone-sdk"
7
+ version = "0.2.0"
8
+ description = "Server-side Python client for Clone prompt predictions."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = ["httpx>=0.28.1,<1", "pydantic>=2.7,<3"]
14
+ classifiers = ["Programming Language :: Python :: 3", "Typing :: Typed"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://clone.is/clone-sdk"
18
+ Repository = "https://github.com/cloneisyou/clone-sdk"
19
+ Documentation = "https://clone.is/docs"
20
+ Issues = "https://github.com/cloneisyou/clone-sdk/issues"
21
+
22
+ [dependency-groups]
23
+ dev = ["pytest>=8.3,<10", "pytest-asyncio>=0.25,<2", "ruff>=0.9,<1", "build>=1.2,<2", "twine>=6,<7"]
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/clone_sdk"]
27
+
28
+ [tool.pytest.ini_options]
29
+ asyncio_mode = "auto"
30
+ testpaths = ["tests"]
31
+
32
+ [tool.ruff]
33
+ target-version = "py311"
34
+ line-length = 110
35
+
36
+ [tool.ruff.lint]
37
+ select = ["E", "F", "I", "UP"]
@@ -0,0 +1,27 @@
1
+ from .client import AsyncCloneClient, CloneClient, CloneError, ConnectionFlow
2
+ from .models import (
3
+ CancelResult,
4
+ Connection,
5
+ ConnectionStarted,
6
+ EventResult,
7
+ FeedbackCleared,
8
+ Prediction,
9
+ RevokeResult,
10
+ Usage,
11
+ )
12
+
13
+ __all__ = [
14
+ "AsyncCloneClient",
15
+ "CloneClient",
16
+ "CloneError",
17
+ "ConnectionFlow",
18
+ "CancelResult",
19
+ "Connection",
20
+ "ConnectionStarted",
21
+ "EventResult",
22
+ "FeedbackCleared",
23
+ "Prediction",
24
+ "RevokeResult",
25
+ "Usage",
26
+ ]
27
+ __version__ = "0.2.0"
@@ -0,0 +1,325 @@
1
+ """Bounded clients. No automatic retries of potentially billable requests."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import hashlib
6
+ import math
7
+ import re
8
+ import secrets
9
+ import threading
10
+ import time
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+ from typing import Any, TypeVar
14
+ from urllib.parse import quote, urlsplit
15
+
16
+ import httpx
17
+ from pydantic import ValidationError
18
+
19
+ from .models import (
20
+ CancelResult,
21
+ Connection,
22
+ ConnectionStarted,
23
+ EventResult,
24
+ FeedbackCleared,
25
+ Prediction,
26
+ ResponseModel,
27
+ RevokeResult,
28
+ Usage,
29
+ )
30
+
31
+ T = TypeVar("T", bound=ResponseModel)
32
+ MAX_RESPONSE_BYTES = 1_048_576
33
+
34
+
35
+ class CloneError(Exception):
36
+ """Safe code and status only. Never contains response text, prompts or credentials."""
37
+
38
+ def __init__(self, code: str, status: int = 0, retry_after: float | None = None):
39
+ self.code, self.status, self.retry_after = code, status, retry_after
40
+ super().__init__(code)
41
+
42
+
43
+ @dataclass(frozen=True, repr=False)
44
+ class ConnectionFlow:
45
+ request_id: str
46
+ state: str
47
+ code_verifier: str
48
+ redirect_uri: str
49
+ user_id: str
50
+
51
+
52
+ def _decode(response: httpx.Response, data: bytes, model: type[T]) -> T:
53
+ import json
54
+
55
+ try:
56
+ body = json.loads(data)
57
+ except (ValueError, UnicodeError):
58
+ body = None
59
+ if not response.is_success:
60
+ detail = body.get("detail") if isinstance(body, dict) else None
61
+ code = detail.get("code") if isinstance(detail, dict) else None
62
+ code = (
63
+ code
64
+ if isinstance(code, str) and re.fullmatch(r"[a-z][a-z0-9_]{0,127}", code)
65
+ else "request_failed"
66
+ )
67
+ try:
68
+ retry_after = float(response.headers["Retry-After"])
69
+ if not math.isfinite(retry_after) or retry_after < 0:
70
+ retry_after = None
71
+ except (KeyError, ValueError):
72
+ retry_after = None
73
+ raise CloneError(code, response.status_code, retry_after)
74
+ try:
75
+ return model.model_validate(body)
76
+ except ValidationError:
77
+ raise CloneError("invalid_response", 502) from None
78
+
79
+
80
+ def _identity(result: Prediction, request: Mapping[str, Any]) -> Prediction:
81
+ draft = request.get("draft", {})
82
+ expected = (
83
+ request.get("request_id"),
84
+ request.get("session_id"),
85
+ request.get("connection_id"),
86
+ draft.get("revision"),
87
+ request.get("context_revision"),
88
+ )
89
+ actual = (
90
+ result.request_id,
91
+ result.session_id,
92
+ result.connection_id,
93
+ result.draft_revision,
94
+ result.context_revision,
95
+ )
96
+ if expected != actual:
97
+ raise CloneError("response_identity_mismatch", 502)
98
+ if result.status == "abstained" and (result.completion or result.usage.prediction_units):
99
+ raise CloneError("invalid_response", 502)
100
+ return result
101
+
102
+
103
+ class _Options:
104
+ def __init__(self, api_key: str, base_url: str, timeout: float, max_concurrent_requests: int):
105
+ url = urlsplit(base_url)
106
+ if url.scheme != "https" and not (
107
+ url.scheme == "http" and url.hostname in {"localhost", "127.0.0.1", "::1"}
108
+ ):
109
+ raise ValueError("Clone API requires HTTPS; loopback HTTP is for development only")
110
+ if not url.hostname or url.username or url.password or url.query or url.fragment:
111
+ raise ValueError("Invalid Clone API URL")
112
+ if not isinstance(api_key, str) or not api_key.startswith("clnp_"):
113
+ raise ValueError("An app-scoped Clone API key is required")
114
+ if isinstance(timeout, bool) or not math.isfinite(timeout) or not 0 < timeout <= 30:
115
+ raise ValueError("timeout must be greater than zero and at most 30 seconds")
116
+ if type(max_concurrent_requests) is not int or not 1 <= max_concurrent_requests <= 1000:
117
+ raise ValueError("max_concurrent_requests must be between 1 and 1000")
118
+ self.base_url, self.timeout, self.limit = base_url.rstrip("/"), timeout, max_concurrent_requests
119
+ self.headers = {"Authorization": "Bearer " + api_key}
120
+
121
+ @staticmethod
122
+ def start_flow(user_id: str, redirect_uri: str):
123
+ verifier, state = secrets.token_urlsafe(48), secrets.token_urlsafe(48)
124
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
125
+ return (
126
+ verifier,
127
+ state,
128
+ {"user_id": user_id, "redirect_uri": redirect_uri, "state": state, "code_challenge": challenge},
129
+ )
130
+
131
+ @staticmethod
132
+ def exchange_body(flow: ConnectionFlow, callback: Mapping[str, str], user_id: str):
133
+ if (
134
+ flow.user_id != user_id
135
+ or callback.get("state") != flow.state
136
+ or callback.get("request_id") != flow.request_id
137
+ or not callback.get("code")
138
+ ):
139
+ raise CloneError("invalid_connect_callback", 400)
140
+ return {
141
+ "request_id": flow.request_id,
142
+ "user_id": user_id,
143
+ "redirect_uri": flow.redirect_uri,
144
+ "code_verifier": flow.code_verifier,
145
+ "code": callback["code"],
146
+ }
147
+
148
+
149
+ class CloneClient(_Options):
150
+ """Synchronous client with connection reuse, per-I/O timeouts and fail-fast concurrency."""
151
+
152
+ def __init__(
153
+ self,
154
+ api_key: str,
155
+ *,
156
+ base_url: str = "https://api.clone.is",
157
+ timeout: float = 15,
158
+ max_concurrent_requests: int = 16,
159
+ transport: httpx.BaseTransport | None = None,
160
+ ):
161
+ super().__init__(api_key, base_url, timeout, max_concurrent_requests)
162
+ self._slots = threading.BoundedSemaphore(self.limit)
163
+ self._client = httpx.Client(
164
+ timeout=timeout,
165
+ follow_redirects=False,
166
+ trust_env=False,
167
+ transport=transport,
168
+ limits=httpx.Limits(max_connections=self.limit, max_keepalive_connections=self.limit),
169
+ )
170
+
171
+ def _call(self, path: str, model: type[T], body: dict | None = None) -> T:
172
+ if not self._slots.acquire(blocking=False):
173
+ raise CloneError("client_capacity_exceeded", 503)
174
+ try:
175
+ start = time.monotonic()
176
+ with self._client.stream(
177
+ "GET" if body is None else "POST",
178
+ self.base_url + "/v1" + path,
179
+ headers=self.headers,
180
+ json=body,
181
+ ) as response:
182
+ data = bytearray()
183
+ for chunk in response.iter_bytes():
184
+ if time.monotonic() - start > self.timeout:
185
+ raise CloneError("request_timeout", 504)
186
+ data.extend(chunk)
187
+ if len(data) > MAX_RESPONSE_BYTES:
188
+ raise CloneError("response_too_large", 502)
189
+ return _decode(response, bytes(data), model)
190
+ except httpx.TimeoutException:
191
+ raise CloneError("request_timeout", 504) from None
192
+ except httpx.HTTPError:
193
+ raise CloneError("network_error") from None
194
+ finally:
195
+ self._slots.release()
196
+
197
+ def predict(self, user_id: str, request: Mapping[str, Any]) -> Prediction:
198
+ return _identity(self._call("/predictions", Prediction, {**request, "user_id": user_id}), request)
199
+
200
+ def record_event(self, user_id: str, event: Mapping[str, Any]) -> EventResult:
201
+ return self._call("/prediction-events", EventResult, {**event, "user_id": user_id})
202
+
203
+ def clear_feedback(self, user_id: str) -> FeedbackCleared:
204
+ return self._call("/prediction-feedback/clear", FeedbackCleared, {"user_id": user_id})
205
+
206
+ def cancel(self, user_id: str, request_id: str) -> CancelResult:
207
+ return self._call(
208
+ f"/predictions/{quote(request_id, safe='')}/cancel", CancelResult, {"user_id": user_id}
209
+ )
210
+
211
+ def usage(self) -> Usage:
212
+ return self._call("/usage", Usage)
213
+
214
+ def revoke(self, user_id: str, connection_id: str) -> RevokeResult:
215
+ return self._call(
216
+ f"/connections/{quote(connection_id, safe='')}/revoke", RevokeResult, {"user_id": user_id}
217
+ )
218
+
219
+ def connect(self, user_id: str, redirect_uri: str) -> tuple[ConnectionStarted, ConnectionFlow]:
220
+ verifier, state, body = self.start_flow(user_id, redirect_uri)
221
+ result = self._call("/connections", ConnectionStarted, body)
222
+ return result, ConnectionFlow(result.request_id, state, verifier, redirect_uri, user_id)
223
+
224
+ def exchange(self, flow: ConnectionFlow, callback: Mapping[str, str], user_id: str) -> Connection:
225
+ return self._call("/connections/exchange", Connection, self.exchange_body(flow, callback, user_id))
226
+
227
+ def close(self):
228
+ self._client.close()
229
+
230
+ def __enter__(self):
231
+ return self
232
+
233
+ def __exit__(self, *_):
234
+ self.close()
235
+
236
+
237
+ class AsyncCloneClient(_Options):
238
+ """Async client with a total deadline including response-body reads, without automatic retries."""
239
+
240
+ def __init__(
241
+ self,
242
+ api_key: str,
243
+ *,
244
+ base_url: str = "https://api.clone.is",
245
+ timeout: float = 15,
246
+ max_concurrent_requests: int = 16,
247
+ transport: httpx.AsyncBaseTransport | None = None,
248
+ ):
249
+ super().__init__(api_key, base_url, timeout, max_concurrent_requests)
250
+ self._in_flight = 0
251
+ self._client = httpx.AsyncClient(
252
+ timeout=timeout,
253
+ follow_redirects=False,
254
+ trust_env=False,
255
+ transport=transport,
256
+ limits=httpx.Limits(max_connections=self.limit, max_keepalive_connections=self.limit),
257
+ )
258
+
259
+ async def _call(self, path: str, model: type[T], body: dict | None = None) -> T:
260
+ if self._in_flight >= self.limit:
261
+ raise CloneError("client_capacity_exceeded", 503)
262
+ self._in_flight += 1
263
+ try:
264
+ async with asyncio.timeout(self.timeout):
265
+ async with self._client.stream(
266
+ "GET" if body is None else "POST",
267
+ self.base_url + "/v1" + path,
268
+ headers=self.headers,
269
+ json=body,
270
+ ) as response:
271
+ data = bytearray()
272
+ async for chunk in response.aiter_bytes():
273
+ data.extend(chunk)
274
+ if len(data) > MAX_RESPONSE_BYTES:
275
+ raise CloneError("response_too_large", 502)
276
+ return _decode(response, bytes(data), model)
277
+ except (TimeoutError, httpx.TimeoutException):
278
+ raise CloneError("request_timeout", 504) from None
279
+ except httpx.HTTPError:
280
+ raise CloneError("network_error") from None
281
+ finally:
282
+ self._in_flight -= 1
283
+
284
+ async def predict(self, user_id: str, request: Mapping[str, Any]) -> Prediction:
285
+ return _identity(
286
+ await self._call("/predictions", Prediction, {**request, "user_id": user_id}), request
287
+ )
288
+
289
+ async def record_event(self, user_id: str, event: Mapping[str, Any]) -> EventResult:
290
+ return await self._call("/prediction-events", EventResult, {**event, "user_id": user_id})
291
+
292
+ async def clear_feedback(self, user_id: str) -> FeedbackCleared:
293
+ return await self._call("/prediction-feedback/clear", FeedbackCleared, {"user_id": user_id})
294
+
295
+ async def cancel(self, user_id: str, request_id: str) -> CancelResult:
296
+ return await self._call(
297
+ f"/predictions/{quote(request_id, safe='')}/cancel", CancelResult, {"user_id": user_id}
298
+ )
299
+
300
+ async def usage(self) -> Usage:
301
+ return await self._call("/usage", Usage)
302
+
303
+ async def revoke(self, user_id: str, connection_id: str) -> RevokeResult:
304
+ return await self._call(
305
+ f"/connections/{quote(connection_id, safe='')}/revoke", RevokeResult, {"user_id": user_id}
306
+ )
307
+
308
+ async def connect(self, user_id: str, redirect_uri: str) -> tuple[ConnectionStarted, ConnectionFlow]:
309
+ verifier, state, body = self.start_flow(user_id, redirect_uri)
310
+ result = await self._call("/connections", ConnectionStarted, body)
311
+ return result, ConnectionFlow(result.request_id, state, verifier, redirect_uri, user_id)
312
+
313
+ async def exchange(self, flow: ConnectionFlow, callback: Mapping[str, str], user_id: str) -> Connection:
314
+ return await self._call(
315
+ "/connections/exchange", Connection, self.exchange_body(flow, callback, user_id)
316
+ )
317
+
318
+ async def close(self):
319
+ await self._client.aclose()
320
+
321
+ async def __aenter__(self):
322
+ return self
323
+
324
+ async def __aexit__(self, *_):
325
+ await self.close()