clairic 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.
clairic/__init__.py ADDED
@@ -0,0 +1,100 @@
1
+ """Unofficial typed Python SDK for Cloudflare Clairic.
2
+
3
+ Clairic is a Workers AI extraction model: it reads a text and returns typed values for named
4
+ fields, with confidence scores, probabilities of absence and percentile ranges. This package is
5
+ not affiliated with or endorsed by Cloudflare.
6
+
7
+ See https://developers.cloudflare.com/workers-ai/models/clairic/ for the model reference.
8
+ """
9
+
10
+ from clairic._client import AsyncClairic, Clairic
11
+ from clairic._version import __version__
12
+ from clairic.answers import (
13
+ Answer,
14
+ DateAnswer,
15
+ DateEstimateAnswer,
16
+ DatetimeAnswer,
17
+ DurationAnswer,
18
+ DurationUnit,
19
+ EstimateAnswer,
20
+ ForecastAnswer,
21
+ ForecastStep,
22
+ ListAnswer,
23
+ NumberAnswer,
24
+ Span,
25
+ TextAnswer,
26
+ TextListAnswer,
27
+ TimeAnswer,
28
+ )
29
+ from clairic.errors import (
30
+ APIConnectionError,
31
+ APIError,
32
+ APITimeoutError,
33
+ AuthenticationError,
34
+ ClairicConfigurationError,
35
+ ClairicError,
36
+ ClairicTruncationWarning,
37
+ ContextWindowExceededError,
38
+ InvalidRequestError,
39
+ ModelDefinitionError,
40
+ )
41
+ from clairic.result import Result, Usage
42
+ from clairic.specs import (
43
+ Date,
44
+ Datetime,
45
+ Duration,
46
+ Estimate,
47
+ Forecast,
48
+ Interval,
49
+ List,
50
+ Number,
51
+ Scale,
52
+ Spec,
53
+ Text,
54
+ Time,
55
+ )
56
+
57
+ __all__ = [
58
+ "APIConnectionError",
59
+ "APIError",
60
+ "APITimeoutError",
61
+ "Answer",
62
+ "AsyncClairic",
63
+ "AuthenticationError",
64
+ "Clairic",
65
+ "ClairicConfigurationError",
66
+ "ClairicError",
67
+ "ClairicTruncationWarning",
68
+ "ContextWindowExceededError",
69
+ "Date",
70
+ "DateAnswer",
71
+ "DateEstimateAnswer",
72
+ "Datetime",
73
+ "DatetimeAnswer",
74
+ "Duration",
75
+ "DurationAnswer",
76
+ "DurationUnit",
77
+ "Estimate",
78
+ "EstimateAnswer",
79
+ "Forecast",
80
+ "ForecastAnswer",
81
+ "ForecastStep",
82
+ "Interval",
83
+ "InvalidRequestError",
84
+ "List",
85
+ "ListAnswer",
86
+ "ModelDefinitionError",
87
+ "Number",
88
+ "NumberAnswer",
89
+ "Result",
90
+ "Scale",
91
+ "Span",
92
+ "Spec",
93
+ "Text",
94
+ "TextAnswer",
95
+ "TextListAnswer",
96
+ "Time",
97
+ "TimeAnswer",
98
+ "Usage",
99
+ "__version__",
100
+ ]
clairic/_client.py ADDED
@@ -0,0 +1,322 @@
1
+ """Sync and async Clairic clients over the Workers AI REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import os
7
+ import warnings
8
+ from types import TracebackType
9
+ from typing import Any, TypeVar
10
+
11
+ import httpx
12
+ from pydantic import BaseModel, ValidationError
13
+ from typing_extensions import Self
14
+
15
+ from clairic._compile import build_data, fields_payload
16
+ from clairic._version import __version__
17
+ from clairic.errors import (
18
+ APIConnectionError,
19
+ APIError,
20
+ APITimeoutError,
21
+ AuthenticationError,
22
+ ClairicConfigurationError,
23
+ ClairicTruncationWarning,
24
+ ContextWindowExceededError,
25
+ InvalidRequestError,
26
+ )
27
+ from clairic.result import Result, Usage
28
+
29
+ M = TypeVar("M", bound=BaseModel)
30
+
31
+ DEFAULT_BASE_URL = "https://api.cloudflare.com/client/v4"
32
+ DEFAULT_TIMEOUT = 30.0
33
+ ACCOUNT_ID_VARIABLE = "CLOUDFLARE_ACCOUNT_ID"
34
+ API_TOKEN_VARIABLE = "CLOUDFLARE_API_TOKEN"
35
+
36
+
37
+ class _ClientBase:
38
+ """Request building and response parsing shared by the sync and async clients."""
39
+
40
+ def __init__(self, *, account_id: str | None, api_token: str | None, base_url: str) -> None:
41
+ account_id = account_id or os.environ.get(ACCOUNT_ID_VARIABLE)
42
+ api_token = api_token or os.environ.get(API_TOKEN_VARIABLE)
43
+ if not account_id:
44
+ raise ClairicConfigurationError(
45
+ f"Pass account_id or set the {ACCOUNT_ID_VARIABLE} environment variable"
46
+ )
47
+ if not api_token:
48
+ raise ClairicConfigurationError(
49
+ f"Pass api_token or set the {API_TOKEN_VARIABLE} environment variable"
50
+ )
51
+ self.account_id = account_id
52
+ self._api_token = api_token
53
+ self._url = f"{base_url.rstrip('/')}/accounts/{account_id}/ai/run/@cf/cloudflare/clairic"
54
+
55
+ def __repr__(self) -> str:
56
+ return f"{type(self).__name__}(account_id={self.account_id!r})"
57
+
58
+ def _headers(self) -> dict[str, str]:
59
+ return {
60
+ "Authorization": f"Bearer {self._api_token}",
61
+ "User-Agent": f"clairic-python/{__version__}",
62
+ }
63
+
64
+ @staticmethod
65
+ def _body(state: str, model: type[BaseModel], reference_date: dt.date | None) -> dict[str, Any]:
66
+ fields = fields_payload(model)
67
+ if not state:
68
+ raise ValueError("state must be a non-empty text")
69
+ body: dict[str, Any] = {"model": "clairic", "state": state, "fields": fields}
70
+ if reference_date is not None:
71
+ if isinstance(reference_date, dt.datetime):
72
+ reference_date = reference_date.date()
73
+ body["reference_date"] = reference_date.isoformat()
74
+ return body
75
+
76
+ @staticmethod
77
+ def _parse(response: httpx.Response, model: type[M]) -> Result[M]:
78
+ status_code = response.status_code
79
+ request_id = response.headers.get("cf-ai-req-id")
80
+ try:
81
+ envelope = _Envelope.model_validate_json(response.content)
82
+ except ValidationError as error:
83
+ raise APIError(
84
+ f"Unexpected response from Cloudflare: {response.text[:500]!r}",
85
+ status_code=status_code,
86
+ request_id=request_id,
87
+ ) from error
88
+ if response.is_error or not envelope.success:
89
+ raise _api_error(status_code, envelope, request_id)
90
+
91
+ try:
92
+ result = _ResultBody.model_validate(envelope.result)
93
+ data = build_data(model, result.answers)
94
+ except ValidationError as error:
95
+ raise APIError(
96
+ f"Clairic returned answers that do not fit {model.__name__}: {error}",
97
+ status_code=status_code,
98
+ request_id=request_id,
99
+ ) from error
100
+ if result.truncated:
101
+ warnings.warn(
102
+ "Clairic truncated the input: the answers were computed on part of it",
103
+ ClairicTruncationWarning,
104
+ stacklevel=3,
105
+ )
106
+ return Result[model](
107
+ data=data,
108
+ usage=result.usage,
109
+ truncated=result.truncated,
110
+ model=result.model,
111
+ request_id=request_id,
112
+ )
113
+
114
+
115
+ class _ErrorItem(BaseModel):
116
+ code: int | None = None
117
+ message: str = ""
118
+
119
+
120
+ class _Envelope(BaseModel):
121
+ """Cloudflare API response envelope."""
122
+
123
+ success: bool = False
124
+ errors: list[_ErrorItem] = []
125
+ result: Any = None
126
+
127
+
128
+ class _ResultBody(BaseModel):
129
+ """`result` of a successful Clairic response."""
130
+
131
+ answers: dict[str, Any]
132
+ truncated: bool
133
+ usage: Usage
134
+ model: str
135
+
136
+
137
+ def _api_error(status_code: int, envelope: _Envelope, request_id: str | None) -> APIError:
138
+ first = envelope.errors[0] if envelope.errors else _ErrorItem()
139
+ message = first.message or f"HTTP {status_code}"
140
+ if status_code == 401:
141
+ error_type: type[APIError] = AuthenticationError
142
+ elif status_code == 413:
143
+ error_type = ContextWindowExceededError
144
+ elif status_code in (400, 422):
145
+ error_type = InvalidRequestError
146
+ else:
147
+ error_type = APIError
148
+ return error_type(message, status_code=status_code, code=first.code, request_id=request_id)
149
+
150
+
151
+ class Clairic(_ClientBase):
152
+ """Client for Cloudflare Clairic.
153
+
154
+ Credentials default to the `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` environment
155
+ variables. Close the client, or use it as a context manager, to release its connections.
156
+
157
+ ```python
158
+ with Clairic() as clairic:
159
+ res = clairic.extract(text, InvoiceExtraction)
160
+ ```
161
+
162
+ Args:
163
+ account_id: str | None
164
+ Cloudflare account id.
165
+ api_token: str | None
166
+ Cloudflare API token.
167
+ base_url: str
168
+ Cloudflare API root, to route requests through a proxy.
169
+ timeout: float | httpx.Timeout
170
+ Seconds to wait for a response. Ignored when `http_client` is given.
171
+ http_client: httpx.Client | None
172
+ HTTP client to send requests with, for custom transports or proxies. The caller
173
+ closes it.
174
+
175
+ Raises:
176
+ ClairicConfigurationError:
177
+ When the account id or the token is neither passed nor set in the environment.
178
+ """
179
+
180
+ def __init__(
181
+ self,
182
+ *,
183
+ account_id: str | None = None,
184
+ api_token: str | None = None,
185
+ base_url: str = DEFAULT_BASE_URL,
186
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
187
+ http_client: httpx.Client | None = None,
188
+ ) -> None:
189
+ super().__init__(account_id=account_id, api_token=api_token, base_url=base_url)
190
+ self._owns_http_client = http_client is None
191
+ self._http_client = http_client or httpx.Client(timeout=timeout)
192
+
193
+ def extract(
194
+ self, state: str, model: type[M], *, reference_date: dt.date | None = None
195
+ ) -> Result[M]:
196
+ """Extract the fields of `model` from `state`.
197
+
198
+ Clairic answers every field in a single pass without generating text, usually in under
199
+ a second. Each field of `model` declares its Clairic type through its answer type, its
200
+ options through an `Annotated` spec, and its instructions through
201
+ `Field(description=...)`.
202
+
203
+ Args:
204
+ state: str
205
+ Text to read, up to Clairic's 65,536-token context window.
206
+ model: type[M]
207
+ Extraction model whose fields are answer types.
208
+ reference_date: date | None
209
+ Date that anchors dates written without a year and relative computations, such
210
+ as "net 30". Set it whenever a field depends on today's date.
211
+
212
+ Returns:
213
+ The answers in an instance of `model`, with the token usage and the request id.
214
+
215
+ Raises:
216
+ ModelDefinitionError:
217
+ When `model` cannot be sent to Clairic, before any request.
218
+ AuthenticationError:
219
+ When Cloudflare rejects the credentials.
220
+ InvalidRequestError:
221
+ When Cloudflare rejects the request content.
222
+ ContextWindowExceededError:
223
+ When `state` is too long.
224
+ APIError:
225
+ For any other error response, or answers that do not fit `model`.
226
+ APIConnectionError:
227
+ When Cloudflare cannot be reached; `APITimeoutError` on timeout.
228
+ """
229
+ body = self._body(state, model, reference_date)
230
+ try:
231
+ response = self._http_client.post(self._url, json=body, headers=self._headers())
232
+ except httpx.TimeoutException as error:
233
+ raise APITimeoutError(f"Clairic did not answer in time: {error}") from error
234
+ except httpx.TransportError as error:
235
+ raise APIConnectionError(f"Could not reach Cloudflare: {error}") from error
236
+ return self._parse(response, model)
237
+
238
+ def close(self) -> None:
239
+ """Close the HTTP connections, unless the HTTP client was passed in."""
240
+ if self._owns_http_client:
241
+ self._http_client.close()
242
+
243
+ def __enter__(self) -> Self:
244
+ return self
245
+
246
+ def __exit__(
247
+ self,
248
+ exc_type: type[BaseException] | None,
249
+ exc: BaseException | None,
250
+ traceback: TracebackType | None,
251
+ ) -> None:
252
+ self.close()
253
+
254
+
255
+ class AsyncClairic(_ClientBase):
256
+ """Async client for Cloudflare Clairic.
257
+
258
+ Same options and behavior as `Clairic`, with `extract` as a coroutine.
259
+
260
+ ```python
261
+ async with AsyncClairic() as clairic:
262
+ res = await clairic.extract(text, InvoiceExtraction)
263
+ ```
264
+
265
+ Args:
266
+ account_id: str | None
267
+ Cloudflare account id.
268
+ api_token: str | None
269
+ Cloudflare API token.
270
+ base_url: str
271
+ Cloudflare API root, to route requests through a proxy.
272
+ timeout: float | httpx.Timeout
273
+ Seconds to wait for a response. Ignored when `http_client` is given.
274
+ http_client: httpx.AsyncClient | None
275
+ HTTP client to send requests with. The caller closes it.
276
+
277
+ Raises:
278
+ ClairicConfigurationError:
279
+ When the account id or the token is neither passed nor set in the environment.
280
+ """
281
+
282
+ def __init__(
283
+ self,
284
+ *,
285
+ account_id: str | None = None,
286
+ api_token: str | None = None,
287
+ base_url: str = DEFAULT_BASE_URL,
288
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
289
+ http_client: httpx.AsyncClient | None = None,
290
+ ) -> None:
291
+ super().__init__(account_id=account_id, api_token=api_token, base_url=base_url)
292
+ self._owns_http_client = http_client is None
293
+ self._http_client = http_client or httpx.AsyncClient(timeout=timeout)
294
+
295
+ async def extract(
296
+ self, state: str, model: type[M], *, reference_date: dt.date | None = None
297
+ ) -> Result[M]:
298
+ """Extract the fields of `model` from `state`. See `Clairic.extract`."""
299
+ body = self._body(state, model, reference_date)
300
+ try:
301
+ response = await self._http_client.post(self._url, json=body, headers=self._headers())
302
+ except httpx.TimeoutException as error:
303
+ raise APITimeoutError(f"Clairic did not answer in time: {error}") from error
304
+ except httpx.TransportError as error:
305
+ raise APIConnectionError(f"Could not reach Cloudflare: {error}") from error
306
+ return self._parse(response, model)
307
+
308
+ async def aclose(self) -> None:
309
+ """Close the HTTP connections, unless the HTTP client was passed in."""
310
+ if self._owns_http_client:
311
+ await self._http_client.aclose()
312
+
313
+ async def __aenter__(self) -> Self:
314
+ return self
315
+
316
+ async def __aexit__(
317
+ self,
318
+ exc_type: type[BaseException] | None,
319
+ exc: BaseException | None,
320
+ traceback: TracebackType | None,
321
+ ) -> None:
322
+ await self.aclose()
clairic/_compile.py ADDED
@@ -0,0 +1,196 @@
1
+ """Translation between extraction models and the Clairic request and response."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import functools
7
+ import re
8
+ from dataclasses import dataclass
9
+ from typing import Any, TypeVar
10
+
11
+ from pydantic import BaseModel
12
+ from pydantic.fields import FieldInfo
13
+
14
+ from clairic.answers import (
15
+ DateAnswer,
16
+ DateEstimateAnswer,
17
+ DatetimeAnswer,
18
+ DurationAnswer,
19
+ EstimateAnswer,
20
+ ForecastAnswer,
21
+ ListAnswer,
22
+ NumberAnswer,
23
+ TextAnswer,
24
+ TextListAnswer,
25
+ TimeAnswer,
26
+ )
27
+ from clairic.errors import ModelDefinitionError
28
+ from clairic.specs import (
29
+ Date,
30
+ Datetime,
31
+ Duration,
32
+ Estimate,
33
+ Forecast,
34
+ List,
35
+ Number,
36
+ Spec,
37
+ Text,
38
+ Time,
39
+ )
40
+
41
+ M = TypeVar("M", bound=BaseModel)
42
+
43
+ FIELD_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\-]{0,63}$")
44
+ """Field names the Clairic API accepts."""
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class _AnswerKind:
49
+ """How an answer class maps to a Clairic field."""
50
+
51
+ spec_type: type[Spec]
52
+ spec_required: bool = False
53
+
54
+
55
+ _KINDS: dict[type[BaseModel], _AnswerKind] = {
56
+ TextAnswer: _AnswerKind(Text),
57
+ NumberAnswer: _AnswerKind(Number),
58
+ DateAnswer: _AnswerKind(Date),
59
+ TimeAnswer: _AnswerKind(Time),
60
+ DatetimeAnswer: _AnswerKind(Datetime),
61
+ DurationAnswer: _AnswerKind(Duration),
62
+ TextListAnswer: _AnswerKind(List),
63
+ ListAnswer: _AnswerKind(List),
64
+ EstimateAnswer: _AnswerKind(Estimate, spec_required=True),
65
+ DateEstimateAnswer: _AnswerKind(Estimate, spec_required=True),
66
+ ForecastAnswer: _AnswerKind(Forecast, spec_required=True),
67
+ }
68
+ _GENERIC = (NumberAnswer, DurationAnswer, ListAnswer, EstimateAnswer)
69
+ _SUPPORTED = ", ".join(
70
+ f"{answer.__name__}[...]" if answer in _GENERIC else answer.__name__ for answer in _KINDS
71
+ )
72
+
73
+
74
+ def fields_payload(model: type[BaseModel]) -> dict[str, dict[str, Any]]:
75
+ """Return the Clairic `fields` object for an extraction model.
76
+
77
+ Raises:
78
+ ModelDefinitionError:
79
+ When a field of `model` cannot be sent to Clairic. The message names the field.
80
+ """
81
+ return {name: dict(spec) for name, spec in _compile(model)}
82
+
83
+
84
+ def build_data(model: type[M], answers: dict[str, Any]) -> M:
85
+ """Return an instance of `model` holding the answers Clairic returned, keyed by field name."""
86
+ return model.model_validate(answers)
87
+
88
+
89
+ @functools.cache
90
+ def _compile(model: type[BaseModel]) -> tuple[tuple[str, dict[str, Any]], ...]:
91
+ if not model.model_fields:
92
+ raise ModelDefinitionError(f"{model.__name__} has no fields; Clairic needs at least one")
93
+ return tuple(
94
+ _compile_field(model, attribute, info) for attribute, info in model.model_fields.items()
95
+ )
96
+
97
+
98
+ def _compile_field(
99
+ model: type[BaseModel], attribute: str, info: FieldInfo
100
+ ) -> tuple[str, dict[str, Any]]:
101
+ where = f"{model.__name__}.{attribute}"
102
+ name = _clairic_name(where, attribute, info)
103
+ origin, argument = _answer_class(where, info.annotation)
104
+ kind = _KINDS[origin]
105
+ spec = _spec(where, info, kind)
106
+
107
+ payload: dict[str, Any] = {"type": spec.field_type}
108
+ if info.description:
109
+ payload["instructions"] = info.description
110
+ payload.update(spec.to_payload())
111
+ payload.update(_type_options(where, origin, argument, spec))
112
+ return name, payload
113
+
114
+
115
+ def _clairic_name(where: str, attribute: str, info: FieldInfo) -> str:
116
+ """Return the field name sent to Clairic: the alias when set, else the attribute name."""
117
+ if info.validation_alias is not None and info.validation_alias != info.alias:
118
+ raise ModelDefinitionError(
119
+ f"{where}: validation_alias is not supported; use alias to rename the field"
120
+ )
121
+ name = info.alias or attribute
122
+ if not FIELD_NAME.match(name):
123
+ raise ModelDefinitionError(
124
+ f"{where}: Clairic field names must match {FIELD_NAME.pattern}, got {name!r}"
125
+ )
126
+ return name
127
+
128
+
129
+ def _answer_class(where: str, annotation: Any) -> tuple[type[BaseModel], Any]:
130
+ """Return the unparametrized answer class of an annotation and its type argument."""
131
+ metadata = getattr(annotation, "__pydantic_generic_metadata__", None)
132
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel) and metadata:
133
+ origin = metadata["origin"] or annotation
134
+ if origin in _KINDS:
135
+ arguments = metadata["args"]
136
+ if origin in _GENERIC and not arguments:
137
+ raise ModelDefinitionError(
138
+ f"{where}: parametrize {origin.__name__} with its value type, "
139
+ f"for example {origin.__name__}[float]"
140
+ )
141
+ return origin, arguments[0] if arguments else None
142
+ raise ModelDefinitionError(
143
+ f"{where}: annotate the field with a Clairic answer type ({_SUPPORTED}), "
144
+ f"got {annotation!r}. Answers already allow a None value; do not wrap them in Optional"
145
+ )
146
+
147
+
148
+ def _spec(where: str, info: FieldInfo, kind: _AnswerKind) -> Spec:
149
+ specs = [item for item in info.metadata if isinstance(item, Spec)]
150
+ if len(specs) > 1:
151
+ raise ModelDefinitionError(f"{where}: declare at most one spec, got {len(specs)}")
152
+ if not specs:
153
+ if kind.spec_required:
154
+ raise ModelDefinitionError(
155
+ f"{where}: add an {kind.spec_type.__name__}(...) spec with Annotated; "
156
+ "its bounds are required"
157
+ )
158
+ return kind.spec_type()
159
+ spec = specs[0]
160
+ if not isinstance(spec, kind.spec_type):
161
+ raise ModelDefinitionError(
162
+ f"{where}: {type(spec).__name__} does not apply to this answer type; "
163
+ f"use {kind.spec_type.__name__}"
164
+ )
165
+ return spec
166
+
167
+
168
+ def _type_options(where: str, origin: type[BaseModel], argument: Any, spec: Spec) -> dict[str, Any]:
169
+ """Return the options implied by the answer type: `integer` and list `items`."""
170
+ if origin in (NumberAnswer, DurationAnswer):
171
+ return {"integer": True} if argument is int else {}
172
+ if origin is TextListAnswer:
173
+ return {"items": "text"}
174
+ if origin is ListAnswer:
175
+ if argument is dt.date:
176
+ return {"items": "date"}
177
+ return {"items": "number", "integer": True} if argument is int else {"items": "number"}
178
+ if isinstance(spec, Estimate):
179
+ _check_estimate_bounds(where, origin, argument, spec)
180
+ return {}
181
+
182
+
183
+ def _check_estimate_bounds(
184
+ where: str, origin: type[BaseModel], argument: Any, spec: Estimate
185
+ ) -> None:
186
+ date_bounds = isinstance(spec.min, dt.date)
187
+ if origin is DateEstimateAnswer and not date_bounds:
188
+ raise ModelDefinitionError(f"{where}: DateEstimateAnswer needs date bounds")
189
+ if origin is EstimateAnswer and date_bounds:
190
+ raise ModelDefinitionError(f"{where}: use DateEstimateAnswer for date bounds")
191
+ if argument is int and not (
192
+ isinstance(spec.step, (int, float)) and float(spec.step).is_integer()
193
+ ):
194
+ raise ModelDefinitionError(
195
+ f"{where}: EstimateAnswer[int] needs a whole-number step, got {spec.step!r}"
196
+ )
clairic/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"