clocked-sdk 0.2.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.
@@ -0,0 +1,46 @@
1
+ """Python client for the Clocked API (https://clocked.rezolve.ai).
2
+
3
+ >>> from clocked_sdk import Clocked
4
+ >>> client = Clocked() # reads CLOCKED_API_KEY
5
+ >>> result = client.decide(
6
+ ... state="My laptop will not turn on, even when connected to its charger.",
7
+ ... questions={"needs_it_help": {"type": "noul", "instructions": "Does the requester need help with an IT problem?"}},
8
+ ... )
9
+ >>> result["answers"], result.model_ms
10
+ """
11
+
12
+ from ._version import __version__
13
+ from .async_client import AsyncClocked
14
+ from .client import DEFAULT_BASE_URL, DEFAULT_MAX_ATTEMPTS, DEFAULT_TIMEOUT, Clocked, Result
15
+ from .errors import (
16
+ APIConnectionError,
17
+ AuthenticationError,
18
+ ClockedError,
19
+ ConflictError,
20
+ InsufficientCreditsError,
21
+ PermissionDeniedError,
22
+ RateLimitError,
23
+ RequestTimeoutError,
24
+ ServerError,
25
+ ValidationError,
26
+ )
27
+
28
+ __all__ = [
29
+ "__version__",
30
+ "Clocked",
31
+ "AsyncClocked",
32
+ "Result",
33
+ "DEFAULT_BASE_URL",
34
+ "DEFAULT_TIMEOUT",
35
+ "DEFAULT_MAX_ATTEMPTS",
36
+ "ClockedError",
37
+ "ValidationError",
38
+ "AuthenticationError",
39
+ "InsufficientCreditsError",
40
+ "PermissionDeniedError",
41
+ "ConflictError",
42
+ "RateLimitError",
43
+ "ServerError",
44
+ "APIConnectionError",
45
+ "RequestTimeoutError",
46
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,472 @@
1
+ """Native asynchronous Clocked client, sharing the synchronous client's contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import uuid
7
+ from collections.abc import Mapping, Sequence
8
+ from typing import Any, Optional, Union
9
+ from urllib.parse import quote
10
+
11
+ import httpx
12
+
13
+ from .client import (
14
+ DEFAULT_BASE_URL, DEFAULT_MAX_ATTEMPTS, DEFAULT_TIMEOUT,
15
+ Images, JSONObject, Result, _BaseClocked, _DISCONNECTS, _TransportFailure, _retry_after, _retryable,
16
+ )
17
+ from .errors import ClockedError, _error_for_response
18
+
19
+ __all__ = ["AsyncClocked"]
20
+
21
+
22
+ class AsyncClocked(_BaseClocked):
23
+ """Asynchronous Clocked API client with the same arguments and results as Clocked.
24
+
25
+ Share one client across concurrent asyncio tasks on the same event loop.
26
+ Every endpoint is awaited. Use ``async with`` or ``await client.aclose()``
27
+ after outstanding requests finish to release the connection pool. Closing
28
+ a client leaves it reusable, like ``Clocked.close()``.
29
+
30
+ Constructor options, error classes, retries, and per-call idempotency
31
+ semantics match Clocked. Cancellation propagates without a retry; stopping
32
+ the local await does not cancel work already accepted by the server.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ api_key: Optional[str] = None,
38
+ *,
39
+ base_url: str = DEFAULT_BASE_URL,
40
+ timeout: float = DEFAULT_TIMEOUT,
41
+ max_attempts: int = DEFAULT_MAX_ATTEMPTS,
42
+ retry_base_delay: float = 0.5,
43
+ retry_max_delay: float = 8.0,
44
+ ) -> None:
45
+ super().__init__(
46
+ api_key, base_url=base_url, timeout=timeout, max_attempts=max_attempts,
47
+ retry_base_delay=retry_base_delay, retry_max_delay=retry_max_delay,
48
+ )
49
+ self._http: Optional[httpx.AsyncClient] = None
50
+
51
+ def __repr__(self) -> str:
52
+ return f"AsyncClocked(base_url={self.base_url!r})"
53
+
54
+ async def __aenter__(self) -> AsyncClocked:
55
+ return self
56
+
57
+ async def __aexit__(self, *exc_info: Any) -> None:
58
+ await self.aclose()
59
+
60
+ async def aclose(self) -> None:
61
+ """Close this client's connections. The client stays usable."""
62
+ client, self._http = self._http, None
63
+ if client is not None:
64
+ await client.aclose()
65
+
66
+ async def close(self) -> None:
67
+ """Alias for :meth:`aclose`; await it to release connections."""
68
+ await self.aclose()
69
+
70
+ # Endpoints ---------------------------------------------------------------
71
+
72
+ async def decide(
73
+ self,
74
+ request: Optional[JSONObject] = None,
75
+ /,
76
+ *,
77
+ state: Union[str, Mapping[str, Any], Sequence[Any], None] = None,
78
+ questions: Optional[Mapping[str, Mapping[str, Any]]] = None,
79
+ images: Optional[Images] = None,
80
+ instructions: Optional[str] = None,
81
+ calibration_temperature: Optional[float] = None,
82
+ rotations: Optional[int] = None,
83
+ documents: Optional[Sequence[Mapping[str, str]]] = None,
84
+ idempotency_key: Union[str, uuid.UUID, None] = None,
85
+ timeout: Optional[float] = None,
86
+ max_attempts: Optional[int] = None,
87
+ ) -> Result:
88
+ """Answer yes/no (``noul``), choice, multiselect and score questions.
89
+
90
+ ``POST /clocked/decide``. Pass ``state`` (text, an object or a list;
91
+ an empty state is answered from the question alone) or ``images``,
92
+ and ``questions`` keyed by name. Returns ``{model, answers, usage}``.
93
+ """
94
+ return await self._call(
95
+ "decide",
96
+ request,
97
+ idempotency_key,
98
+ timeout,
99
+ max_attempts,
100
+ state=state,
101
+ questions=questions,
102
+ images=images,
103
+ instructions=instructions,
104
+ calibration_temperature=calibration_temperature,
105
+ rotations=rotations,
106
+ documents=documents,
107
+ )
108
+
109
+ async def extract(
110
+ self,
111
+ request: Optional[JSONObject] = None,
112
+ /,
113
+ *,
114
+ text: Optional[str] = None,
115
+ images: Optional[Images] = None,
116
+ fields: Optional[Mapping[str, Union[str, Mapping[str, Any]]]] = None,
117
+ instructions: Optional[str] = None,
118
+ idempotency_key: Union[str, uuid.UUID, None] = None,
119
+ timeout: Optional[float] = None,
120
+ max_attempts: Optional[int] = None,
121
+ ) -> Result:
122
+ """Extract values verbatim from ``text`` or ``images``.
123
+
124
+ ``POST /clocked/extract``. Each of ``fields`` (1 to 64) is a
125
+ description string, an empty dict, or a dict with ``description``,
126
+ ``list`` and ``max_items``.
127
+ """
128
+ return await self._call(
129
+ "extract",
130
+ request,
131
+ idempotency_key,
132
+ timeout,
133
+ max_attempts,
134
+ text=text,
135
+ images=images,
136
+ fields=fields,
137
+ instructions=instructions,
138
+ )
139
+
140
+ async def fill(
141
+ self,
142
+ request: Optional[JSONObject] = None,
143
+ /,
144
+ *,
145
+ text: Optional[str] = None,
146
+ images: Optional[Images] = None,
147
+ fields: Optional[Mapping[str, Mapping[str, Any]]] = None,
148
+ instructions: Optional[str] = None,
149
+ reference_date: Optional[str] = None,
150
+ idempotency_key: Union[str, uuid.UUID, None] = None,
151
+ timeout: Optional[float] = None,
152
+ max_attempts: Optional[int] = None,
153
+ ) -> Result:
154
+ """Fill a complete record: extract, choice, multiselect, score, yes_no and generate fields.
155
+
156
+ ``POST /clocked/fill``. ``reference_date`` (``YYYY-MM-DD``) anchors
157
+ relative dates for generated date fields.
158
+ """
159
+ return await self._call(
160
+ "fill",
161
+ request,
162
+ idempotency_key,
163
+ timeout,
164
+ max_attempts,
165
+ text=text,
166
+ images=images,
167
+ fields=fields,
168
+ instructions=instructions,
169
+ reference_date=reference_date,
170
+ )
171
+
172
+ async def pii(
173
+ self,
174
+ request: Optional[JSONObject] = None,
175
+ /,
176
+ *,
177
+ text: Optional[str] = None,
178
+ images: Optional[Images] = None,
179
+ entities: Union[Sequence[str], Mapping[str, Any], None] = None,
180
+ allow: Optional[Sequence[str]] = None,
181
+ deny: Union[Sequence[str], Mapping[str, str], None] = None,
182
+ anonymize: Union[str, Mapping[str, str], None] = None,
183
+ threshold: Optional[float] = None,
184
+ mask_keep: Optional[int] = None,
185
+ max_items: Optional[int] = None,
186
+ instructions: Optional[str] = None,
187
+ idempotency_key: Union[str, uuid.UUID, None] = None,
188
+ timeout: Optional[float] = None,
189
+ max_attempts: Optional[int] = None,
190
+ ) -> Result:
191
+ """Find personal data and return a transformed copy of the text.
192
+
193
+ ``POST /clocked/pii``. ``entities`` lists catalog kinds or maps kinds
194
+ to custom descriptions (default: nine catalog kinds). ``anonymize`` is
195
+ ``replace``, ``mask``, ``hash``, ``redact`` or a per-kind mapping.
196
+ """
197
+ return await self._call(
198
+ "pii",
199
+ request,
200
+ idempotency_key,
201
+ timeout,
202
+ max_attempts,
203
+ text=text,
204
+ images=images,
205
+ entities=entities,
206
+ allow=allow,
207
+ deny=deny,
208
+ anonymize=anonymize,
209
+ threshold=threshold,
210
+ mask_keep=mask_keep,
211
+ max_items=max_items,
212
+ instructions=instructions,
213
+ )
214
+
215
+ async def injection(
216
+ self,
217
+ request: Optional[JSONObject] = None,
218
+ /,
219
+ *,
220
+ text: Optional[str] = None,
221
+ images: Optional[Images] = None,
222
+ source: Optional[str] = None,
223
+ categories: Optional[Sequence[str]] = None,
224
+ instructions: Optional[str] = None,
225
+ threshold: Optional[float] = None,
226
+ evidence: Optional[bool] = None,
227
+ idempotency_key: Union[str, uuid.UUID, None] = None,
228
+ timeout: Optional[float] = None,
229
+ max_attempts: Optional[int] = None,
230
+ ) -> Result:
231
+ """Detect prompt injection in a user message or in retrieved content.
232
+
233
+ ``POST /clocked/injection``. ``source`` is ``user`` (default) or
234
+ ``content``.
235
+ """
236
+ return await self._call(
237
+ "injection",
238
+ request,
239
+ idempotency_key,
240
+ timeout,
241
+ max_attempts,
242
+ text=text,
243
+ images=images,
244
+ source=source,
245
+ categories=categories,
246
+ instructions=instructions,
247
+ threshold=threshold,
248
+ evidence=evidence,
249
+ )
250
+
251
+ async def script(
252
+ self,
253
+ request: Optional[JSONObject] = None,
254
+ /,
255
+ *,
256
+ script: Optional[str] = None,
257
+ images: Optional[Images] = None,
258
+ categories: Optional[Sequence[str]] = None,
259
+ instructions: Optional[str] = None,
260
+ threshold: Optional[float] = None,
261
+ evidence: Optional[bool] = None,
262
+ rotations: Optional[int] = None,
263
+ idempotency_key: Union[str, uuid.UUID, None] = None,
264
+ timeout: Optional[float] = None,
265
+ max_attempts: Optional[int] = None,
266
+ ) -> Result:
267
+ """Classify whether a script's behavior is harmful, with the lines that explain it.
268
+
269
+ ``POST /clocked/script``. The script is read as data; nothing runs.
270
+ """
271
+ return await self._call(
272
+ "script",
273
+ request,
274
+ idempotency_key,
275
+ timeout,
276
+ max_attempts,
277
+ script=script,
278
+ images=images,
279
+ categories=categories,
280
+ instructions=instructions,
281
+ threshold=threshold,
282
+ evidence=evidence,
283
+ rotations=rotations,
284
+ )
285
+
286
+ async def grounding(
287
+ self,
288
+ request: Optional[JSONObject] = None,
289
+ /,
290
+ *,
291
+ documents: Optional[Sequence[Union[str, Mapping[str, Any]]]] = None,
292
+ images: Optional[Images] = None,
293
+ answer: Optional[str] = None,
294
+ question: Optional[str] = None,
295
+ claims: Optional[Sequence[str]] = None,
296
+ instructions: Optional[str] = None,
297
+ threshold: Optional[float] = None,
298
+ evidence: Optional[str] = None,
299
+ rotations: Optional[int] = None,
300
+ idempotency_key: Union[str, uuid.UUID, None] = None,
301
+ timeout: Optional[float] = None,
302
+ max_attempts: Optional[int] = None,
303
+ ) -> Result:
304
+ """Check each claim in ``answer`` against ``documents`` or ``images``.
305
+
306
+ ``POST /clocked/grounding``. ``evidence`` is ``all``, ``flagged``
307
+ (default) or ``none``.
308
+ """
309
+ return await self._call(
310
+ "grounding",
311
+ request,
312
+ idempotency_key,
313
+ timeout,
314
+ max_attempts,
315
+ documents=documents,
316
+ images=images,
317
+ answer=answer,
318
+ question=question,
319
+ claims=claims,
320
+ instructions=instructions,
321
+ threshold=threshold,
322
+ evidence=evidence,
323
+ rotations=rotations,
324
+ )
325
+
326
+ async def compare(
327
+ self,
328
+ request: Optional[JSONObject] = None,
329
+ /,
330
+ *,
331
+ documents: Optional[Sequence[Union[str, Mapping[str, Any]]]] = None,
332
+ threshold: Optional[float] = None,
333
+ evidence: Optional[str] = None,
334
+ rotations: Optional[int] = None,
335
+ kinds: Optional[bool] = None,
336
+ debug: Optional[bool] = None,
337
+ instructions: Optional[str] = None,
338
+ idempotency_key: Union[str, uuid.UUID, None] = None,
339
+ timeout: Optional[float] = None,
340
+ max_attempts: Optional[int] = None,
341
+ ) -> Result:
342
+ """Find duplicates, shared facts and conflicts across 2 to 20 text documents.
343
+
344
+ ``POST /clocked/compare``. ``evidence`` is ``flagged`` (default),
345
+ ``all`` or ``none``. Compare reads text only.
346
+ """
347
+ return await self._call(
348
+ "compare",
349
+ request,
350
+ idempotency_key,
351
+ timeout,
352
+ max_attempts,
353
+ documents=documents,
354
+ threshold=threshold,
355
+ evidence=evidence,
356
+ rotations=rotations,
357
+ kinds=kinds,
358
+ debug=debug,
359
+ instructions=instructions,
360
+ )
361
+
362
+ async def _call(
363
+ self,
364
+ endpoint: str,
365
+ request: Optional[JSONObject],
366
+ idempotency_key: Union[str, uuid.UUID, None],
367
+ timeout: Optional[float],
368
+ max_attempts: Optional[int],
369
+ /,
370
+ **fields: Any,
371
+ ) -> Result:
372
+ # Only local serialization/file reads use a worker. HTTP and backoff
373
+ # run natively on the event loop, with cancellation between attempts.
374
+ payload, key, wait, attempts = await asyncio.to_thread(
375
+ self._prepare, request, idempotency_key, timeout, max_attempts, **fields
376
+ )
377
+ return await self._send(endpoint, payload, key, wait, attempts)
378
+
379
+ async def _send(self, endpoint: str, payload: bytes, key: str, timeout: float, max_attempts: int) -> Result:
380
+ outcome_unknown = False
381
+ refreshed = False
382
+ attempt = 0
383
+ while True:
384
+ attempt += 1
385
+ retry_after: Optional[float] = None
386
+ try:
387
+ status, headers, raw = await self._post(endpoint, payload, key, timeout)
388
+ except _TransportFailure as transport:
389
+ cause = transport.cause
390
+ if transport.reused and not refreshed and _is_disconnect(cause):
391
+ # Match Clocked's one immediate, uncounted refresh of a
392
+ # reused connection. The same key safely recovers its work.
393
+ refreshed = True
394
+ attempt -= 1
395
+ outcome_unknown = True
396
+ continue
397
+ failure: ClockedError = self._connection_error(
398
+ cause, key, attempt, timeout, timed_out=isinstance(cause, httpx.TimeoutException)
399
+ )
400
+ retry = True
401
+ outcome_unknown = True
402
+ else:
403
+ if 200 <= status < 300:
404
+ return self._result(status, headers, raw, key, attempt)
405
+ failure = _error_for_response(status, headers, raw, idempotency_key=key, attempts=attempt)
406
+ retry = _retryable(status, failure.retry_as_new, outcome_unknown)
407
+ if status >= 500 and not failure.retry_as_new:
408
+ outcome_unknown = True
409
+ retry_after = _retry_after(headers)
410
+ if not retry or attempt >= max_attempts:
411
+ raise failure
412
+ await asyncio.sleep(self._delay(attempt, retry_after))
413
+
414
+ def _connection(self) -> httpx.AsyncClient:
415
+ if self._http is None:
416
+ proxy = self._proxy
417
+ proxy_url = None
418
+ if proxy is not None:
419
+ host = f"[{proxy.host}]" if ":" in proxy.host else proxy.host
420
+ proxy_url = httpx.Proxy(f"http://{host}:{proxy.port}", headers=proxy.headers)
421
+ # SDK retry state is the only retry policy. Proxy selection and TLS
422
+ # context are shared with the sync client, including OS proxy rules.
423
+ transport = httpx.AsyncHTTPTransport(
424
+ verify=self._ssl if self._ssl is not None else True,
425
+ proxy=proxy_url,
426
+ retries=0,
427
+ trust_env=False,
428
+ limits=httpx.Limits(max_connections=None, max_keepalive_connections=20),
429
+ )
430
+ self._http = httpx.AsyncClient(transport=transport, follow_redirects=False, trust_env=False)
431
+ return self._http
432
+
433
+ async def _post(self, endpoint: str, payload: bytes, key: str, timeout: float) -> tuple[int, dict[str, str], bytes]:
434
+ reused = True
435
+
436
+ async def trace(event_name: str, info: dict[str, Any]) -> None:
437
+ nonlocal reused
438
+ if event_name in ("connection.connect_tcp.started", "connection.connect_unix_socket.started"):
439
+ reused = False
440
+
441
+ try:
442
+ async with self._connection().stream(
443
+ "POST",
444
+ f"{self.base_url}/{quote(endpoint)}",
445
+ content=payload,
446
+ headers=self._request_headers(key),
447
+ timeout=timeout,
448
+ extensions={"trace": trace},
449
+ ) as response:
450
+ # Like the sync transport, request identity encoding and pass
451
+ # the actual response bytes to our shared JSON/error decoder.
452
+ raw = b"".join([part async for part in response.aiter_raw()])
453
+ return response.status_code, dict(response.headers), raw
454
+ except httpx.TransportError as cause:
455
+ raise _TransportFailure(cause, reused) from cause
456
+
457
+
458
+ def _is_disconnect(cause: BaseException) -> bool:
459
+ """HTTPX's wrappers around the sync client's refreshable disconnects."""
460
+ seen: set[int] = set()
461
+ pending = [cause]
462
+ while pending:
463
+ current = pending.pop()
464
+ if id(current) in seen:
465
+ continue
466
+ seen.add(id(current))
467
+ if isinstance(current, _DISCONNECTS):
468
+ return True
469
+ if isinstance(current, httpx.RemoteProtocolError) and str(current) == "Server disconnected without sending a response.":
470
+ return True
471
+ pending.extend(error for error in (current.__cause__, current.__context__) if error is not None)
472
+ return False