bynn-decisionnode 0.3.1__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,218 @@
1
+ """Exceptions raised by the DecisionNode client.
2
+
3
+ Every exception derives from :class:`DecisionNodeError`. Errors that come from an
4
+ HTTP response carry the status and the ``error_type`` from the body, the request id
5
+ and the server's explanation (``reason``) from the response headers. The exception's
6
+ own message (``str(error)``) is the server's ``message``, or the client's description
7
+ when no response was received.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional
13
+
14
+ from ._boosters import BoosterStatus, header, parse_boosters
15
+
16
+ if TYPE_CHECKING:
17
+ from ._types import Safety
18
+
19
+ __all__ = [
20
+ "DecisionNodeError",
21
+ "BadRequestError",
22
+ "ContextLimitError",
23
+ "AuthenticationError",
24
+ "InsufficientCreditError",
25
+ "RefusalError",
26
+ "PermissionDeniedError",
27
+ "NotFoundError",
28
+ "RequestTooLargeError",
29
+ "ValidationError",
30
+ "RateLimitError",
31
+ "SessionLimitError",
32
+ "OverloadedError",
33
+ "ServerError",
34
+ "ConnectionError",
35
+ "TimeoutError",
36
+ ]
37
+
38
+
39
+ class DecisionNodeError(Exception):
40
+ """Base class of every error this package raises.
41
+
42
+ Also raised as it is for a status without a class of its own (a redirect, which
43
+ is never followed, or a status such as 405, 409 or 418), with :attr:`status` set.
44
+
45
+ ``str(error)`` is the server's ``message`` exactly: ``detail.message`` when the
46
+ error body has one, ``detail`` itself when the body carries a plain string, else
47
+ the empty string (never ``None``). When the body is not JSON (an HTML or text page,
48
+ or no body), it is the client's ``HTTP <status> <text>``, for example
49
+ ``HTTP 413 Content Too Large``, with `` (request id <id>)`` when the response has an
50
+ ``x-request-id``. Errors raised without a response carry the client's description
51
+ instead.
52
+
53
+ Attributes:
54
+ status: The HTTP status, or ``None`` when no response was received.
55
+ error_type: ``detail.error_type`` from the error body, when there is one.
56
+ message: The same text as ``str(error)``.
57
+ request_id: The ``x-request-id`` response header.
58
+ reason: The server's explanation in words, from the ``x-decisionnode-error``
59
+ header, for example ``question 'q': a score takes up to 10 levels, got 11``.
60
+ body: The parsed error body, or ``None`` when it is not JSON (an HTML or text
61
+ page, or no body): such a body is never read, and the message is then
62
+ ``HTTP <status> <text>``, with the request id when the response has one.
63
+ headers: The response headers, with lower-case names.
64
+ boosters: For a request that named boosters, what each booster did, from the
65
+ ``x-decisionnode-boosters`` header: booster name to :class:`BoosterStatus`, in
66
+ the request's order, so a caller can see which required booster failed.
67
+ Empty when the header is absent.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ message: Optional[str] = None,
73
+ *,
74
+ status: Optional[int] = None,
75
+ error_type: Optional[str] = None,
76
+ request_id: Optional[str] = None,
77
+ reason: Optional[str] = None,
78
+ body: Any = None,
79
+ headers: Optional[Mapping[str, str]] = None,
80
+ ) -> None:
81
+ self.status = status
82
+ self.error_type = error_type
83
+ self.request_id = request_id
84
+ self.reason = reason
85
+ self.body = body
86
+ self.headers: dict[str, str] = dict(headers or {})
87
+ self.boosters: Dict[str, BoosterStatus] = parse_boosters(header(self.headers))
88
+ super().__init__(message if isinstance(message, str) else "")
89
+
90
+ @property
91
+ def message(self) -> str:
92
+ """The error's message: the same text as ``str(error)``."""
93
+ return str(self.args[0]) if self.args else ""
94
+
95
+ def __repr__(self) -> str:
96
+ parts = [repr(self.message)]
97
+ for name in ("status", "error_type", "request_id", "reason"):
98
+ value = getattr(self, name)
99
+ if value is not None:
100
+ parts.append("%s=%r" % (name, value))
101
+ return "%s(%s)" % (self.__class__.__name__, ", ".join(parts))
102
+
103
+
104
+ class BadRequestError(DecisionNodeError):
105
+ """400: the request is well formed but cannot be used."""
106
+
107
+
108
+ class ContextLimitError(BadRequestError):
109
+ """400 with ``error_type`` ``max_tokens_exceeded``: the request is over the model's limit.
110
+
111
+ The count and the limit are in :attr:`reason`.
112
+ """
113
+
114
+
115
+ class AuthenticationError(DecisionNodeError):
116
+ """401: the API key is missing or not valid.
117
+
118
+ Also raised before any request is sent when no key was configured.
119
+ """
120
+
121
+
122
+ class InsufficientCreditError(DecisionNodeError):
123
+ """402: the account is out of credit."""
124
+
125
+
126
+ class RefusalError(DecisionNodeError):
127
+ """403 with ``error_type`` ``refusal_error``: the request was refused under the usage policy. Never retried.
128
+
129
+ Attributes:
130
+ category: ``harm`` or ``self_harm``.
131
+ safety: What the safety check found, parsed from ``x-decisionnode-safety``.
132
+ """
133
+
134
+ def __init__(
135
+ self,
136
+ message: Optional[str] = None,
137
+ *,
138
+ category: Optional[str] = None,
139
+ safety: Optional[Safety] = None,
140
+ **kwargs: Any,
141
+ ) -> None:
142
+ self.category = category
143
+ self.safety = safety
144
+ super().__init__(message, **kwargs)
145
+
146
+
147
+ class PermissionDeniedError(DecisionNodeError):
148
+ """403 that is not a refusal: the workspace may not do this, for example use a booster
149
+ that is turned off for it (``error_type`` ``booster_not_enabled``). Never retried."""
150
+
151
+
152
+ class NotFoundError(DecisionNodeError):
153
+ """404: the path does not exist."""
154
+
155
+
156
+ class RequestTooLargeError(DecisionNodeError):
157
+ """413, or found before sending: the request body is over the API's limit of
158
+ 26,214,400 bytes (25 MiB). Never retried. Send large images as uploads and name them
159
+ by ``upload_id``; images given as a path or bytes over the inline threshold already
160
+ are."""
161
+
162
+
163
+ class ValidationError(DecisionNodeError):
164
+ """422: the request does not have the right shape. The message is empty; the
165
+ problems are in :attr:`entries` and summed up in :attr:`reason`.
166
+
167
+ Attributes:
168
+ entries: The server's list of problems, one entry per field.
169
+ """
170
+
171
+ def __init__(self, message: Optional[str] = None, *, entries: Optional[list[Any]] = None, **kwargs: Any) -> None:
172
+ self.entries = entries
173
+ super().__init__(message, **kwargs)
174
+
175
+
176
+ class RateLimitError(DecisionNodeError):
177
+ """429: too many requests.
178
+
179
+ Attributes:
180
+ retry_after: Seconds the server asked to wait, capped at 60, or ``None``.
181
+ """
182
+
183
+ def __init__(self, message: Optional[str] = None, *, retry_after: Optional[float] = None, **kwargs: Any) -> None:
184
+ self.retry_after = retry_after
185
+ super().__init__(message, **kwargs)
186
+
187
+
188
+ class SessionLimitError(RateLimitError):
189
+ """429 with ``error_type`` ``session_limit_exceeded``: the key already holds as many open
190
+ sessions as it may. Never retried; end a session or wait for one to expire.
191
+
192
+ A subclass of :class:`RateLimitError`, so code that catches that catches this too.
193
+ """
194
+
195
+
196
+ class OverloadedError(DecisionNodeError):
197
+ """529: the server is overloaded, or (``error_type`` ``booster_unavailable``) a booster
198
+ the request required failed or did not answer in time. Retried like every 529.
199
+
200
+ Attributes:
201
+ retry_after: Seconds the server asked to wait, capped at 60, or ``None``.
202
+ """
203
+
204
+ def __init__(self, message: Optional[str] = None, *, retry_after: Optional[float] = None, **kwargs: Any) -> None:
205
+ self.retry_after = retry_after
206
+ super().__init__(message, **kwargs)
207
+
208
+
209
+ class ServerError(DecisionNodeError):
210
+ """Any other 5xx status, or a 2xx response whose body is not JSON."""
211
+
212
+
213
+ class ConnectionError(DecisionNodeError): # noqa: A001 - the name is part of the public surface
214
+ """No response was received: the connection failed or was dropped."""
215
+
216
+
217
+ class TimeoutError(DecisionNodeError): # noqa: A001 - the name is part of the public surface
218
+ """No response arrived within the client's timeout."""
@@ -0,0 +1,283 @@
1
+ """Images sent beside the state: the ``Image`` type, format detection and header parsing.
2
+
3
+ Local bytes are checked here, without decoding the image: the format from its first
4
+ bytes, its size, and its pixel dimensions from the header (JPEG SOF markers, the PNG
5
+ IHDR chunk, the WebP VP8, VP8L and VP8X headers).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import binascii
12
+ import hashlib
13
+ import os
14
+ import struct
15
+ from typing import Any, Dict, Optional, Tuple, Union
16
+
17
+ __all__ = ["Image", "DEFAULT_INLINE_THRESHOLD", "image_dimensions", "sniff_media_type"]
18
+
19
+ DEFAULT_INLINE_THRESHOLD = 524_288
20
+ """Local images of one request (or session frame, or batch line) that total fewer bytes
21
+ than this go inline as base64; at or above it they are uploaded first. The client's
22
+ ``inline_threshold`` overrides it."""
23
+
24
+ MEDIA_TYPES = ("image/jpeg", "image/png", "image/webp")
25
+ MAX_IMAGE_BYTES = 10_485_760
26
+ MAX_IMAGE_PIXELS = 40_000_000
27
+ MAX_URL_CHARS = 2048
28
+
29
+ # Start-of-frame markers carry the frame's height and width. C4 (DHT), C8 (reserved)
30
+ # and CC (DAC) share the range but are not frames.
31
+ _JPEG_SOF = frozenset(range(0xC0, 0xD0)) - {0xC4, 0xC8, 0xCC}
32
+ # Markers that stand alone, without a length: TEM, RST0 to RST7, SOI.
33
+ _JPEG_STANDALONE = frozenset({0x01, 0xD8} | set(range(0xD0, 0xD8)))
34
+
35
+
36
+ def sniff_media_type(data: bytes) -> Optional[str]:
37
+ """The media type of JPEG, PNG or WebP bytes, read from their first bytes."""
38
+ if data[:3] == b"\xff\xd8\xff":
39
+ return "image/jpeg"
40
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
41
+ return "image/png"
42
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
43
+ return "image/webp"
44
+ return None
45
+
46
+
47
+ def _png_dimensions(data: bytes) -> Optional[Tuple[int, int]]:
48
+ # Signature (8), then the IHDR chunk: length (4), type (4), width (4), height (4).
49
+ if len(data) < 24 or data[12:16] != b"IHDR":
50
+ return None
51
+ width, height = struct.unpack(">II", data[16:24])
52
+ return width, height
53
+
54
+
55
+ def _jpeg_dimensions(data: bytes) -> Optional[Tuple[int, int]]:
56
+ i = 2
57
+ n = len(data)
58
+ while i < n:
59
+ if data[i] != 0xFF:
60
+ return None
61
+ # Any number of 0xFF fill bytes may precede a marker.
62
+ while i < n and data[i] == 0xFF:
63
+ i += 1
64
+ if i >= n:
65
+ return None
66
+ marker = data[i]
67
+ i += 1
68
+ if marker in _JPEG_STANDALONE:
69
+ continue
70
+ if marker in (0xD9, 0xDA): # end of image, or the scan began before any frame header
71
+ return None
72
+ if i + 2 > n:
73
+ return None
74
+ (length,) = struct.unpack(">H", data[i : i + 2])
75
+ if length < 2:
76
+ return None
77
+ if marker in _JPEG_SOF:
78
+ # Length (2), precision (1), height (2), width (2).
79
+ if i + 7 > n:
80
+ return None
81
+ height, width = struct.unpack(">HH", data[i + 3 : i + 7])
82
+ return width, height
83
+ i += length
84
+ return None
85
+
86
+
87
+ def _webp_dimensions(data: bytes) -> Optional[Tuple[int, int]]:
88
+ if len(data) < 30:
89
+ return None
90
+ chunk = data[12:16]
91
+ if chunk == b"VP8 ":
92
+ # Frame tag (3 bytes), start code 9D 01 2A, then 14-bit width and height.
93
+ if data[23:26] != b"\x9d\x01\x2a":
94
+ return None
95
+ width, height = struct.unpack("<HH", data[26:30])
96
+ return width & 0x3FFF, height & 0x3FFF
97
+ if chunk == b"VP8L":
98
+ # Signature 0x2F, then width - 1 and height - 1 in 14 bits each.
99
+ if data[20] != 0x2F:
100
+ return None
101
+ bits = int.from_bytes(data[21:25], "little")
102
+ return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
103
+ if chunk == b"VP8X":
104
+ # Flags (1), reserved (3), canvas width - 1 (3), canvas height - 1 (3).
105
+ width = int.from_bytes(data[24:27], "little") + 1
106
+ height = int.from_bytes(data[27:30], "little") + 1
107
+ return width, height
108
+ return None
109
+
110
+
111
+ def image_dimensions(data: bytes, media_type: Optional[str] = None) -> Optional[Tuple[int, int]]:
112
+ """``(width, height)`` read from the image's header, without decoding it.
113
+
114
+ ``None`` when the bytes are not JPEG, PNG or WebP or the header cannot be read.
115
+
116
+ >>> image_dimensions(b"\\x89PNG\\r\\n\\x1a\\n\\x00\\x00\\x00\\rIHDR\\x00\\x00\\x00\\x03\\x00\\x00\\x00\\x05")
117
+ (3, 5)
118
+ """
119
+ kind = media_type or sniff_media_type(data)
120
+ try:
121
+ if kind == "image/png":
122
+ dims = _png_dimensions(data)
123
+ elif kind == "image/jpeg":
124
+ dims = _jpeg_dimensions(data)
125
+ elif kind == "image/webp":
126
+ dims = _webp_dimensions(data)
127
+ else:
128
+ return None
129
+ except (struct.error, IndexError):
130
+ return None
131
+ if dims is None or dims[0] <= 0 or dims[1] <= 0:
132
+ return None
133
+ return dims
134
+
135
+
136
+ def bytes_fault(image_id: str, data: bytes, declared: Optional[str]) -> Optional[str]:
137
+ """What the API would refuse in these bytes, in its words, or ``None``."""
138
+ if len(data) > MAX_IMAGE_BYTES:
139
+ return "Image %s is larger than 10 MB." % image_id
140
+ if declared is not None and declared not in MEDIA_TYPES:
141
+ return "Image %s: media_type must be image/jpeg, image/png or image/webp." % image_id
142
+ found = sniff_media_type(data)
143
+ if found is None:
144
+ if declared is not None:
145
+ return "Image %s could not be decoded as %s." % (image_id, declared)
146
+ return "Image %s is not a JPEG, PNG or WebP image." % image_id
147
+ if declared is not None and declared != found:
148
+ return "Image %s could not be decoded as %s." % (image_id, declared)
149
+ dims = image_dimensions(data, found)
150
+ if dims is None:
151
+ return "Image %s could not be decoded as %s." % (image_id, found)
152
+ if dims[0] * dims[1] > MAX_IMAGE_PIXELS:
153
+ return "Image %s is %dx%d pixels, over the limit of 40 megapixels." % (image_id, dims[0], dims[1])
154
+ return None
155
+
156
+
157
+ class Image:
158
+ """An image every question in the request can see, together with the state.
159
+
160
+ Give exactly one source::
161
+
162
+ Image("receipt", url="https://example.com/receipt.jpg") # fetched by the server; the fastest
163
+ Image("receipt", path="receipt.jpg") # read here
164
+ Image("receipt", data=raw_bytes) # bytes you already have
165
+ Image("receipt", data="iVBORw0KGgo...") # base64 text
166
+ Image("receipt", upload_id="upl_...") # an earlier upload
167
+
168
+ The id is unique within a request. The model sees the image as "Picture <n>" (its
169
+ place in the request's images, from 1) with this id beside it, so text in the state
170
+ or the instructions can refer to it by either.
171
+
172
+ A file is read when the ``Image`` is made; a file that cannot be read raises
173
+ ``ValueError`` here. Local bytes are checked before any request, without decoding
174
+ the image: JPEG, PNG or WebP by their first bytes (a ``media_type`` given with them
175
+ must match), at most 10 MB, at most 40 megapixels by the header. A fault raises
176
+ :class:`BadRequestError` when the image is sent, as the API would.
177
+
178
+ Small local images go inline as base64; when the local images of a request total
179
+ ``inline_threshold`` bytes or more they are uploaded first and sent by ``upload_id``.
180
+ An ``Image`` can be reused across calls; its checks and hashes are computed once.
181
+ """
182
+
183
+ __slots__ = ("id", "url", "upload_id", "data", "media_type", "declared", "fault", "_sha256", "_md5", "_b64")
184
+
185
+ def __init__(
186
+ self,
187
+ id: str, # noqa: A002 - the wire name
188
+ path: Union[str, os.PathLike[str], None] = None,
189
+ *,
190
+ data: Union[bytes, bytearray, memoryview, str, None] = None,
191
+ media_type: Optional[str] = None,
192
+ url: Optional[str] = None,
193
+ upload_id: Optional[str] = None,
194
+ ) -> None:
195
+ sources = sum(x is not None for x in (path, data, url, upload_id))
196
+ if sources != 1:
197
+ raise ValueError("Image needs exactly one of path, data, url or upload_id")
198
+ if not isinstance(id, str):
199
+ raise ValueError("Image id must be a string, got %r" % (id,))
200
+ self.id = id
201
+ self.url = url
202
+ self.upload_id = upload_id
203
+ self.data: Optional[bytes] = None
204
+ self.declared: Optional[str] = None
205
+ self.media_type: Optional[str] = None
206
+ # What the API would refuse in these bytes, raised as BadRequestError when sent.
207
+ self.fault: Optional[str] = None
208
+ self._sha256: Optional[bytes] = None
209
+ self._md5: Optional[bytes] = None
210
+ self._b64: Optional[str] = None
211
+ if url is not None:
212
+ if not isinstance(url, str):
213
+ raise ValueError("image %r: url must be a string" % id)
214
+ if media_type is not None:
215
+ raise ValueError("media_type goes with data, a path or an upload_id, not with url")
216
+ return
217
+ if upload_id is not None:
218
+ if not isinstance(upload_id, str):
219
+ raise ValueError("image %r: upload_id must be a string" % id)
220
+ self.media_type = media_type
221
+ return
222
+ if path is not None:
223
+ try:
224
+ with open(path, "rb") as fh:
225
+ data = fh.read()
226
+ except OSError as exc:
227
+ raise ValueError("image %r: cannot read %s: %s" % (id, os.fspath(path), exc.strerror or exc)) from exc
228
+ raw: bytes
229
+ if isinstance(data, str):
230
+ try:
231
+ raw = base64.b64decode(data, validate=True)
232
+ except (binascii.Error, ValueError):
233
+ self.data = b""
234
+ self.declared = media_type
235
+ self.media_type = media_type
236
+ self.fault = "Image %s: data is not valid base64." % id
237
+ return
238
+ elif isinstance(data, (bytes, bytearray, memoryview)):
239
+ raw = bytes(data)
240
+ else:
241
+ raise ValueError("image %r: data must be bytes or base64 text" % id)
242
+ self.data = raw
243
+ self.declared = media_type
244
+ self.fault = bytes_fault(id, raw, media_type)
245
+ self.media_type = media_type or sniff_media_type(raw)
246
+
247
+ # ---- values computed once and reused across calls
248
+
249
+ def sha256(self) -> bytes:
250
+ """The SHA-256 digest of the bytes."""
251
+ if self._sha256 is None:
252
+ self._sha256 = hashlib.sha256(self.data or b"").digest()
253
+ return self._sha256
254
+
255
+ def md5(self) -> bytes:
256
+ """The MD5 digest of the bytes, the storage's integrity check."""
257
+ if self._md5 is None:
258
+ self._md5 = hashlib.md5(self.data or b"").digest()
259
+ return self._md5
260
+
261
+ def base64(self) -> str:
262
+ """The bytes in base64."""
263
+ if self._b64 is None:
264
+ self._b64 = base64.b64encode(self.data or b"").decode("ascii")
265
+ return self._b64
266
+
267
+ def to_dict(self) -> Dict[str, Any]:
268
+ """The image in its wire form, with local bytes inline."""
269
+ if self.url is not None:
270
+ return {"id": self.id, "url": self.url}
271
+ if self.upload_id is not None:
272
+ out: Dict[str, Any] = {"id": self.id, "upload_id": self.upload_id}
273
+ if self.media_type is not None:
274
+ out["media_type"] = self.media_type
275
+ return out
276
+ return {"id": self.id, "media_type": self.media_type, "data": self.base64()}
277
+
278
+ def __repr__(self) -> str:
279
+ if self.url is not None:
280
+ return "Image(%r, url=%r)" % (self.id, self.url)
281
+ if self.upload_id is not None:
282
+ return "Image(%r, upload_id=%r)" % (self.id, self.upload_id)
283
+ return "Image(%r, media_type=%r, %d bytes)" % (self.id, self.media_type, len(self.data or b""))