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.
- clone_sdk-0.2.0/.gitignore +23 -0
- clone_sdk-0.2.0/LICENSE +21 -0
- clone_sdk-0.2.0/PKG-INFO +99 -0
- clone_sdk-0.2.0/README.md +82 -0
- clone_sdk-0.2.0/pyproject.toml +37 -0
- clone_sdk-0.2.0/src/clone_sdk/__init__.py +27 -0
- clone_sdk-0.2.0/src/clone_sdk/client.py +325 -0
- clone_sdk-0.2.0/src/clone_sdk/models.py +76 -0
- clone_sdk-0.2.0/src/clone_sdk/py.typed +0 -0
- clone_sdk-0.2.0/tests/test_client.py +263 -0
- clone_sdk-0.2.0/uv.lock +921 -0
|
@@ -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
|
clone_sdk-0.2.0/LICENSE
ADDED
|
@@ -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.
|
clone_sdk-0.2.0/PKG-INFO
ADDED
|
@@ -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()
|