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.
- bynn_decisionnode-0.3.1.dist-info/METADATA +939 -0
- bynn_decisionnode-0.3.1.dist-info/RECORD +19 -0
- bynn_decisionnode-0.3.1.dist-info/WHEEL +4 -0
- bynn_decisionnode-0.3.1.dist-info/licenses/LICENSE +21 -0
- decisionnode/__init__.py +195 -0
- decisionnode/_base.py +548 -0
- decisionnode/_batches.py +333 -0
- decisionnode/_boosters.py +553 -0
- decisionnode/_client.py +2269 -0
- decisionnode/_errors.py +218 -0
- decisionnode/_images.py +283 -0
- decisionnode/_questions.py +262 -0
- decisionnode/_sessions.py +711 -0
- decisionnode/_types.py +764 -0
- decisionnode/_uploads.py +265 -0
- decisionnode/_validate.py +934 -0
- decisionnode/_version.py +5 -0
- decisionnode/_videos.py +197 -0
- decisionnode/py.typed +0 -0
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bynn-decisionnode
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: The official Python client for the DecisionNode API.
|
|
5
|
+
Project-URL: Homepage, https://decisionnode.com
|
|
6
|
+
Project-URL: Repository, https://github.com/Bynn-Intelligence/decision-node-python
|
|
7
|
+
Project-URL: Changelog, https://github.com/Bynn-Intelligence/decision-node-python/blob/main/CHANGELOG.md
|
|
8
|
+
Author: Bynn Intelligence, Inc.
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,classification,decision,decisionnode,sdk,websocket
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Framework :: AsyncIO
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: httpx<1,>=0.25
|
|
26
|
+
Requires-Dist: typing-extensions>=4.5
|
|
27
|
+
Requires-Dist: websockets==16.1.1
|
|
28
|
+
Provides-Extra: http2
|
|
29
|
+
Requires-Dist: h2==4.4.1; extra == 'http2'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# DecisionNode Python SDK
|
|
33
|
+
|
|
34
|
+
The official Python client for the DecisionNode API: ask yes-or-no, multiple-choice,
|
|
35
|
+
scale and number questions about any text, JSON or image, and get calibrated
|
|
36
|
+
probabilities back. One request at a time, a live session answering a stream of frames,
|
|
37
|
+
or a batch of thousands at half the price. Sync and async clients, fully typed.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
pip install bynn-decisionnode
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
or `uv add bynn-decisionnode`. Python 3.10 or later.
|
|
46
|
+
|
|
47
|
+
## Set your API key
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
export DECISIONNODE_API_KEY="your-api-key"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quickstart
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from decisionnode import DecisionNode, truth, choice, score
|
|
57
|
+
|
|
58
|
+
dn = DecisionNode() # reads DECISIONNODE_API_KEY
|
|
59
|
+
|
|
60
|
+
d = dn.decide(
|
|
61
|
+
state={"ticket": "I was charged twice and nobody has replied for 3 days."},
|
|
62
|
+
questions={
|
|
63
|
+
"route": choice("Where should this go?", {"billing": "money", "bug": "broken", "account": "login"}),
|
|
64
|
+
"urgency": score("How urgent is this?", ["routine", "today", "urgent", "critical"]),
|
|
65
|
+
"escalate": truth("Escalate to a human now?"),
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
d["route"].choice # "billing"
|
|
70
|
+
d["route"].probabilities # {"billing": 0.87, "bug": 0.08, "account": 0.05}
|
|
71
|
+
d["urgency"].score # 2.31
|
|
72
|
+
d["escalate"].truth # 0.94
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The same with asyncio:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import asyncio
|
|
79
|
+
from decisionnode import AsyncDecisionNode, truth
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def main() -> None:
|
|
83
|
+
async with AsyncDecisionNode() as dn:
|
|
84
|
+
d = await dn.decide(state="Refund my order now!", questions={"angry": truth("Is the customer angry?")})
|
|
85
|
+
print(d["angry"].truth)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
asyncio.run(main())
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Questions
|
|
92
|
+
|
|
93
|
+
Six question types. Each builder returns the question in its wire form, and a plain
|
|
94
|
+
`dict` in that form is accepted anywhere a builder is.
|
|
95
|
+
|
|
96
|
+
| Builder | Asks | Answer |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `truth(instructions, if_true=None, if_false=None)` | Is this true? | `TruthAnswer.truth`: the probability of yes, 0 to 1 |
|
|
99
|
+
| `choice(instructions, options)` | Which one? `options` is a dict of name to description, or a list of names | `ChoiceAnswer.choice`, `.confidence`, `.probabilities` (by option name) |
|
|
100
|
+
| `score(instructions, levels)` | Where on this scale? `levels` are ordered low to high | `ScoreAnswer.score` (expected level index), `.confidence`, `.probabilities` and `.legend` (by level index, as `int`) |
|
|
101
|
+
| `number(instructions, min, max, step)` | Which number? A value of the grid `min`, `min + step`, ... up to `max` (defaults 0, 255 and 1) | `NumberAnswer.number` (the most likely value), `.expected` (the weighted mean), `.confidence`, `.probabilities` (by the value as the API writes it: `"7"`, `"3.5"`) |
|
|
102
|
+
| `point(instructions, image=None, units=None)` | Where in the image? (below) | `PointAnswer.x`, `.y`, `.present`, `.confidence`, `.image`, `.width`, `.height` |
|
|
103
|
+
| `box(instructions, image=None, units=None)` | Draw a box around it (below) | `BoxAnswer.x1`, `.y1`, `.x2`, `.y2`, `.present`, `.confidence`, `.image`, `.width`, `.height` |
|
|
104
|
+
|
|
105
|
+
`state`, `instructions`, option descriptions and levels take a string or any
|
|
106
|
+
JSON-serialisable value. Question ids are any string and come back unchanged; read
|
|
107
|
+
answers by id, never by position.
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
truth("Is this a refund request?", if_true="the customer asks for money back")
|
|
111
|
+
choice("Which language is this?", ["en", "de", "sv"])
|
|
112
|
+
score({"question": "How complete is the form?", "focus": "required fields"}, ["empty", "partial", "complete"])
|
|
113
|
+
number("How many cars are visible?", min=0, max=50)
|
|
114
|
+
number("Rating in halves?", min=0, max=5, step=0.5)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
A number grid holds at most 4,096 values, with at most 6 decimals in `min` and `step`.
|
|
118
|
+
`number` leaves out of the request any of `min`, `max` and `step` you do not give, so the
|
|
119
|
+
API's defaults apply.
|
|
120
|
+
|
|
121
|
+
The values are the ones the API sent: probabilities are not rounded or renormalised,
|
|
122
|
+
so a list can sum to 0.99 or 1.01, and an entry can read `0.8200000000000001`. An
|
|
123
|
+
answer of a type this version does not know comes back as `UnknownAnswer` with its
|
|
124
|
+
fields in `.raw`, and so does an answer of a known type with a missing or wrongly typed
|
|
125
|
+
field; the rest of the decision parses as usual.
|
|
126
|
+
|
|
127
|
+
## One question at a time
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
dn.check("Package arrived crushed.", "Is the customer reporting damage?").truth # 0.97
|
|
131
|
+
dn.classify("Where is my parcel?", "Topic?", ["shipping", "billing", "returns"]).choice # "shipping"
|
|
132
|
+
dn.score("Site is down for everyone!", "Severity?", ["low", "medium", "high"]).score # 1.86
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
dn.number("Drone frame over row B4.", "How many cars?", min=0, max=50).number # 7
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`check(state, instructions, if_true, if_false)`, `classify(state, instructions, options)`,
|
|
140
|
+
`score(state, instructions, levels)` and `number(state, instructions, min, max, step)`
|
|
141
|
+
send one question and return its answer. Like
|
|
142
|
+
`decide`, each of them and `gate` also take `images=` and `model=`. They promise a typed
|
|
143
|
+
answer, so when the server's answer to their question is missing or malformed they raise
|
|
144
|
+
`ServerError` (`status` 200, `reason` from `x-decisionnode-error`), where `decide`
|
|
145
|
+
would return the same answer as `UnknownAnswer`.
|
|
146
|
+
|
|
147
|
+
`gate(action, context)` judges an action before it runs. It sends the state
|
|
148
|
+
`{"action": ..., "context": ...}` and three questions, worded the same in every official
|
|
149
|
+
DecisionNode SDK, and returns the `Decision`:
|
|
150
|
+
|
|
151
|
+
| Id | Type | Question |
|
|
152
|
+
|---|---|---|
|
|
153
|
+
| `risk` | score | "How much harm could this action cause if it is wrong?", levels `none`, `low`, `moderate`, `high`, `severe` |
|
|
154
|
+
| `touches_production` | truth | "Does this action change a production system, production data or anything customers use?" |
|
|
155
|
+
| `verdict` | choice | "Should this action run as it is, wait for a person to confirm it, or be blocked?", options `allow` "safe to run without asking", `confirm` "a person should confirm it first", `block` "should not run" |
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
g = dn.gate("DROP TABLE invoices;", {"environment": "production"})
|
|
159
|
+
if g["verdict"].choice != "allow":
|
|
160
|
+
ask_a_person()
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The thresholds are yours: the SDK never turns a probability into a decision.
|
|
164
|
+
|
|
165
|
+
## The decision
|
|
166
|
+
|
|
167
|
+
| Field | |
|
|
168
|
+
|---|---|
|
|
169
|
+
| `answers` | The answers by question id. `d["route"]` is `d.answers["route"]`. |
|
|
170
|
+
| `model` | The model version that answered; for a fine-tuned model its pinned name (below). |
|
|
171
|
+
| `usage.input_tokens`, `usage.output_tokens` | Token counts, or `None` when the server sent none (never `0` in their place). |
|
|
172
|
+
| `usage.video_frames`, `usage.video_seconds` | For a request with videos: the frames the server read and the seconds of video they cover; `None` otherwise. |
|
|
173
|
+
| `usage.booster_tokens`, `usage.boosters` | For a request with boosters: the share of `input_tokens` the boosters' readings take, and the booster calls billed; `None` otherwise. |
|
|
174
|
+
| `usage.cost` | What the answer cost in USD, as a `Cost`: `usd`, `model_usd`, `boosters_usd` and `free_tier`, read as the server sent them and never summed by the SDK; `None` when the server sent none. A session's open and each frame reply carry their own. |
|
|
175
|
+
| `evidence` | For a request with boosters: what each booster read (below); `None` otherwise. |
|
|
176
|
+
| `fallback` | On a fallback answer, the model that answered and the model asked for (below); `None` otherwise. |
|
|
177
|
+
| `warnings` | What the server changed when it read the request's videos (a lower rate or size), as sent; an empty list when there is nothing to say. |
|
|
178
|
+
| `request_id` | The request id. Quote it when you contact support. |
|
|
179
|
+
| `latency_ms` | The server's own time for the request, or `None` when the server sent none. |
|
|
180
|
+
| `safety` | What the safety check found (below), or `None`. |
|
|
181
|
+
| `raw` | The parsed response body. |
|
|
182
|
+
|
|
183
|
+
## Many requests
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from decisionnode import DecisionNodeError
|
|
187
|
+
|
|
188
|
+
results = dn.decide_many(
|
|
189
|
+
[{"state": t, "questions": {"spam": truth("Is this spam?")}} for t in tickets],
|
|
190
|
+
concurrency=8,
|
|
191
|
+
)
|
|
192
|
+
for r in results:
|
|
193
|
+
if isinstance(r, DecisionNodeError):
|
|
194
|
+
print("failed:", r)
|
|
195
|
+
else:
|
|
196
|
+
print(r["spam"].truth)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
At most `concurrency` requests run at once (default 8; below 1 raises `ValueError`
|
|
200
|
+
before anything is sent). Every request is checked before the first is sent, so a bad
|
|
201
|
+
argument in any of them raises `ValueError`, a request the API would refuse raises its
|
|
202
|
+
error class, and nothing is sent. Results come back in input order, one `DecisionResult`
|
|
203
|
+
per request: a `Decision`, or the error that request ended with. Images with the same
|
|
204
|
+
bytes are uploaded once for all the requests. One failure does not stop
|
|
205
|
+
the others.
|
|
206
|
+
|
|
207
|
+
## Images
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from decisionnode import Image
|
|
211
|
+
|
|
212
|
+
d = dn.decide(
|
|
213
|
+
state={"claim": {"total": "740.00", "currency": "SEK"}},
|
|
214
|
+
images=[Image("receipt", url="https://files.example.com/claims/4411/receipt.jpg")],
|
|
215
|
+
questions={"total_matches": truth("Does the total on the receipt equal the total in the claim?")},
|
|
216
|
+
)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Send images by URL when you can.** It is the fastest input: nothing is uploaded, the
|
|
220
|
+
request stays small, and the server fetches the image while it reads the request. A URL
|
|
221
|
+
must be `https` and at most 2,048 characters.
|
|
222
|
+
|
|
223
|
+
The other forms:
|
|
224
|
+
|
|
225
|
+
| Form | |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `Image(id, url=...)` | An `https` URL the server fetches. The recommended form |
|
|
228
|
+
| `Image(id, path=...)` | A file, read when the `Image` is made |
|
|
229
|
+
| `Image(id, data=...)` | Bytes, or the same bytes as base64 text |
|
|
230
|
+
| `Image(id, upload_id=...)` | An image uploaded before (below), for an hour |
|
|
231
|
+
|
|
232
|
+
Local images (a path or data) are checked before anything is sent, without decoding them:
|
|
233
|
+
JPEG, PNG or WebP by their first bytes (a `media_type` you give must match them), at most
|
|
234
|
+
10 MB each, at most 40 megapixels, read from the image's header. A file that cannot be read
|
|
235
|
+
raises `ValueError` when the `Image` is made; an image the API would refuse raises
|
|
236
|
+
`BadRequestError` before any request. A request holds at most 16 images, URLs included; a
|
|
237
|
+
17th raises `ValidationError` before any request, as the API answers it (a `too_long`
|
|
238
|
+
entry on the images).
|
|
239
|
+
|
|
240
|
+
How local images travel is chosen for speed:
|
|
241
|
+
|
|
242
|
+
- When the local images of a request total under the inline threshold (512 KiB,
|
|
243
|
+
`DEFAULT_INLINE_THRESHOLD`; set `inline_threshold` on the client to change it) they go
|
|
244
|
+
inline in the request.
|
|
245
|
+
- At or above it they are uploaded first: one `POST /v1/uploads` for all of them, then one
|
|
246
|
+
`PUT` each, in parallel, straight to storage, and the request names them by
|
|
247
|
+
`upload_id`. The `PUT` carries only the headers the upload was signed with: never your
|
|
248
|
+
key, never your custom headers.
|
|
249
|
+
- The client remembers what it uploaded, by the SHA-256 of the bytes, for as long as the
|
|
250
|
+
upload lives (until 5 minutes before it expires, at most 256 images). The same image sent
|
|
251
|
+
again, in another call, in `decide_many`, in a session frame or a batch line, goes by its
|
|
252
|
+
`upload_id` whatever its size, without being uploaded again. When the server reports such
|
|
253
|
+
an upload as expired or gone, the client uploads the image again and sends the request
|
|
254
|
+
once more.
|
|
255
|
+
|
|
256
|
+
A request body may be at most 25 MiB (26,214,400 bytes). Inline images count toward it;
|
|
257
|
+
uploaded images and URLs do not. The client measures each request as it will be sent,
|
|
258
|
+
`decide_many` requests and batch lines included, and raises `RequestTooLargeError` before
|
|
259
|
+
anything is sent or uploaded when it is over. Images given as a path or bytes above the
|
|
260
|
+
inline threshold are uploaded, so only a large state or base64 data you send yourself can
|
|
261
|
+
reach the limit.
|
|
262
|
+
|
|
263
|
+
An `Image` can be reused across calls; its checks and hashes are computed once.
|
|
264
|
+
|
|
265
|
+
### Upload ahead
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
upload_ids = dn.upload([Image("front", path="front.jpg"), Image("back", path="back.jpg")])
|
|
269
|
+
d = dn.decide(state="...", images=[Image("front", upload_id=upload_ids[0])], questions={...})
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`upload(images)` uploads 1 to 16 images whatever their size and returns one `upload_id` per image. An
|
|
273
|
+
upload can be used for one hour by any key of your workspace.
|
|
274
|
+
|
|
275
|
+
### Referring to images and videos
|
|
276
|
+
|
|
277
|
+
There are two ways to say which image a question is about:
|
|
278
|
+
|
|
279
|
+
1. **By id (recommended).** Give each image a short, meaningful id and use it in the
|
|
280
|
+
instructions, in backticks: "Does the total on `receipt` match the amount in the claim?"
|
|
281
|
+
2. **By number.** Images are numbered from 1 in the order of `images`: "image 2", "the
|
|
282
|
+
second image", "Picture 2" and "the last image" all work.
|
|
283
|
+
|
|
284
|
+
The model sees each image as `Picture 1 (receipt):`, `Picture 2 (claim_photo):`, so both
|
|
285
|
+
ways point to the same image. Prefer ids: they stay right when images are added or
|
|
286
|
+
reordered. Use names, not digits, as ids (`front`, not `2`). Videos work the same way, by id
|
|
287
|
+
or by number ("video 1"), numbered separately in the order of `videos`. The `image` of a
|
|
288
|
+
point or box question takes the id.
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
d = dn.decide(
|
|
292
|
+
state="Expense claim 4411: 740.00 EUR.",
|
|
293
|
+
images=[
|
|
294
|
+
Image("receipt", url="https://files.example.com/claims/4411/receipt.jpg"),
|
|
295
|
+
Image("claim_photo", url="https://files.example.com/claims/4411/photo.jpg"),
|
|
296
|
+
],
|
|
297
|
+
questions={"matches": truth("Does the total on `receipt` match the amount in the claim?")},
|
|
298
|
+
)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Points and boxes
|
|
302
|
+
|
|
303
|
+
A `point` question finds one spot in an image; a `box` question draws a rectangle around
|
|
304
|
+
something. They mix freely with the other types in one request, and several of them on
|
|
305
|
+
the same image share one read of it.
|
|
306
|
+
|
|
307
|
+
```python
|
|
308
|
+
from decisionnode import Image, box, point, truth
|
|
309
|
+
|
|
310
|
+
d = dn.decide(
|
|
311
|
+
images=[Image("belt", url="https://files.example.com/line-3/cam-2/0917.jpg")],
|
|
312
|
+
questions={
|
|
313
|
+
"pick": point("Point to the yellow package closest to the robot arm."),
|
|
314
|
+
"damage": box("Draw a box around the damaged package.", image="belt"),
|
|
315
|
+
"any_damage": truth("Is any package on the belt damaged?"),
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
pick = d["pick"]
|
|
320
|
+
if pick.present > 0.8 and pick.confidence > 0.7:
|
|
321
|
+
robot.pick(pick.x, pick.y)
|
|
322
|
+
else:
|
|
323
|
+
send_to_operator()
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
| Field | |
|
|
327
|
+
|---|---|
|
|
328
|
+
| `x`, `y` | The point, from the top-left corner of the image: `x` to the right, `y` down. Whole pixels by default; with `units="normalized"`, fractions of the width and height with 4 decimals |
|
|
329
|
+
| `x1`, `y1`, `x2`, `y2` | The box: its top-left corner (`x1`, `y1`) and bottom-right corner (`x2`, `y2`), in the same units |
|
|
330
|
+
| `present` | The probability that what you asked for is in the image at all. Under a low value the place is only the best guess |
|
|
331
|
+
| `confidence` | The probability that the place is right, if the thing is there: for a point, that it lies on the thing; for a box, that it overlaps the true box by at least half (intersection over union of 0.5 or more) |
|
|
332
|
+
| `image`, `width`, `height` | The image the answer refers to and its size in pixels, so you can draw or convert the answer without another lookup |
|
|
333
|
+
|
|
334
|
+
Every field is always there. When several things match, the answer is the best match;
|
|
335
|
+
say which one you mean in the instructions ("the leftmost yellow package").
|
|
336
|
+
|
|
337
|
+
A point or box question needs an image in the request. With two or more images, name the
|
|
338
|
+
one to look in with `image`, the image's id; a reference by number ("Picture 2") goes in
|
|
339
|
+
the instructions. These rules and `units` (`"pixels"` or `"normalized"`) are
|
|
340
|
+
checked before anything is sent, and a fault raises `ValidationError` as the API would.
|
|
341
|
+
`decide`, `decide_many` and batch requests take point and box questions.
|
|
342
|
+
|
|
343
|
+
## Videos
|
|
344
|
+
|
|
345
|
+
A video is sent as the frames you sampled, each with its time in seconds, and the
|
|
346
|
+
questions see them as one video, in order and with their times. Only frames are sent: no video file, no video URL and no sound go to the API.
|
|
347
|
+
|
|
348
|
+
```python
|
|
349
|
+
from decisionnode import VideoFrame, Video
|
|
350
|
+
|
|
351
|
+
frames = [VideoFrame(t, jpeg) for t, jpeg in camera_frames] # (seconds, JPEG bytes) you already hold
|
|
352
|
+
|
|
353
|
+
d = dn.decide(
|
|
354
|
+
state="Warehouse camera 4, loading dock, shift B.",
|
|
355
|
+
videos=[Video("dock", frames, fps=2)],
|
|
356
|
+
questions={
|
|
357
|
+
"fall": truth("Does a person fall in the video?"),
|
|
358
|
+
"forklifts": number("How many forklifts pass the door?", min=0, max=50),
|
|
359
|
+
},
|
|
360
|
+
)
|
|
361
|
+
d["fall"].truth
|
|
362
|
+
d.usage.video_frames, d.warnings
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
| Argument | |
|
|
366
|
+
|---|---|
|
|
367
|
+
| `Video(id, frames, fps=None, start=None, end=None)` | `id` unique across the request's images and videos. `fps` caps the frames read a second (above 0, at most 4; the API reads 2 by default). `start` and `end` set the window read, on the frames' own times; `end` is after `start`. Left out, the API's defaults apply |
|
|
368
|
+
| `VideoFrame(t, data, media_type=None)` | `t` in seconds, at least 0, larger than the frame before it. `data` is JPEG, PNG or WebP bytes (the type is read from them) or base64 text with its `media_type` |
|
|
369
|
+
|
|
370
|
+
The ids, times, media types, `fps` and the window are checked before anything is sent, and
|
|
371
|
+
a fault raises `BadRequestError` as the API would. A request holds at most 4 videos; a fifth
|
|
372
|
+
raises `ValidationError` (a `too_long` entry on the videos). The
|
|
373
|
+
frame limits are the API's and are published with it. Frames always go inline: they are
|
|
374
|
+
never uploaded or resized. `decide`, the helpers, `decide_many` and batch requests take
|
|
375
|
+
`videos`; sessions do not.
|
|
376
|
+
|
|
377
|
+
To turn a video file into frames, use the `dn` command-line tool (`dn truth "Does a person
|
|
378
|
+
fall?" --video dock.mp4 --fps 2`); it runs FFmpeg on your machine and sends only the frames.
|
|
379
|
+
This package has no such dependency.
|
|
380
|
+
|
|
381
|
+
## Boosters
|
|
382
|
+
|
|
383
|
+
A booster is a specialist model that reads the request's images before the decision. Its
|
|
384
|
+
reading is added to what the model sees, and comes back in `evidence`. The model still
|
|
385
|
+
answers every question: a booster never answers one, and a request without boosters is
|
|
386
|
+
answered exactly as before.
|
|
387
|
+
|
|
388
|
+
| Booster | Reads | Tells the model |
|
|
389
|
+
|---|---|---|
|
|
390
|
+
| `age` | A picture of a person | How old the person is, with a range and a confidence (the largest face is read) |
|
|
391
|
+
| `ai_generated` | Any picture | How likely the picture was made by an image generator, which generator most likely, and whether the file carries Content Credentials (C2PA) and who signed them |
|
|
392
|
+
|
|
393
|
+
```python
|
|
394
|
+
from decisionnode import Image, truth
|
|
395
|
+
|
|
396
|
+
d = dn.decide(
|
|
397
|
+
images=[
|
|
398
|
+
Image("a1b2", url="https://files.example.com/a1b2.jpg"),
|
|
399
|
+
Image("c3d4", url="https://files.example.com/c3d4.jpg"),
|
|
400
|
+
],
|
|
401
|
+
boosters=["age", "ai_generated"],
|
|
402
|
+
questions={
|
|
403
|
+
"adult": truth("Is the person in the first picture an adult?"),
|
|
404
|
+
"synthetic": truth("Is the second picture AI-generated?"),
|
|
405
|
+
},
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
d["adult"].truth # 0.97
|
|
409
|
+
age = d.evidence["age"]
|
|
410
|
+
age.status, age.version # "ok", "age-v3"
|
|
411
|
+
age.results[0].age_years, age.results[0].low, age.results[0].high # 27, 22, 33
|
|
412
|
+
d.evidence["ai_generated"].results[1].generated # 0.93
|
|
413
|
+
d.usage.booster_tokens, d.usage.boosters # 72, 4
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
`boosters` is a list of booster names, in the order you want them reported. `decide`, the
|
|
417
|
+
helpers, `decide_many` requests and batch requests take it. Sessions do not.
|
|
418
|
+
|
|
419
|
+
A booster that needs a setting is a `Booster`. Here `ai_generated` reads only the second
|
|
420
|
+
picture, and the request fails if it cannot:
|
|
421
|
+
|
|
422
|
+
```python
|
|
423
|
+
from decisionnode import Booster
|
|
424
|
+
|
|
425
|
+
d = dn.decide(
|
|
426
|
+
images=images,
|
|
427
|
+
boosters=["age", Booster("ai_generated", images=["c3d4"], require=True)],
|
|
428
|
+
questions=questions,
|
|
429
|
+
)
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
| Argument | |
|
|
433
|
+
|---|---|
|
|
434
|
+
| `Booster(name, images=None, require=None, options=None)` | `images`: the ids of the request's images the booster reads; left out, every image. `require=True`: a booster that fails or does not answer in time fails the request with `OverloadedError` (`error_type` `booster_unavailable`), which is retried like every 529; left out, the decision runs without that reading and `evidence` says why. `options`: settings of a booster, for boosters that have any |
|
|
435
|
+
|
|
436
|
+
It is sent as `{"name": "ai_generated", "images": ["c3d4"], "require": true}`, each setting
|
|
437
|
+
only when given; a plain mapping in that form works too. A `Booster` with no setting is sent
|
|
438
|
+
as its name, so the request stays the plain list.
|
|
439
|
+
|
|
440
|
+
The client checks nothing about boosters itself: the API answers a malformed list, an
|
|
441
|
+
unknown name and a booster turned off for your workspace before any booster runs and
|
|
442
|
+
before any charge. A booster added to the API works with this version of the package.
|
|
443
|
+
|
|
444
|
+
A request whose boosters name `ocr` waits 30 seconds per attempt by default; a `timeout`
|
|
445
|
+
you set on the client is used as given. It is never retried after a timeout, because the
|
|
446
|
+
server finishes that attempt and may bill it: set a timeout of at least 30 seconds for such
|
|
447
|
+
requests.
|
|
448
|
+
|
|
449
|
+
`d.evidence` maps each booster the request named, in its order, to an `Evidence`:
|
|
450
|
+
|
|
451
|
+
| Field | |
|
|
452
|
+
|---|---|
|
|
453
|
+
| `version` | The version of the booster that read, such as `age-v3` |
|
|
454
|
+
| `status` | `ok`; `timeout` (no answer in time); `error`; `skipped` (none of the request's images is one it reads) |
|
|
455
|
+
| `latency_ms` | How long the booster took |
|
|
456
|
+
| `billed_calls` | The calls of this booster billed on this request |
|
|
457
|
+
| `results` | One reading per image the booster was to read, each naming its `image`. A picture it did not read (it timed out or failed) or could not decode (`readable` false) is an `UnknownReading` holding those fields, and `status` says why |
|
|
458
|
+
|
|
459
|
+
| Reading | Fields |
|
|
460
|
+
|---|---|
|
|
461
|
+
| `AgeReading` | `image`, `faces` (how many faces were found; the largest is read), and `age_years`, `low`, `high`, `confidence`, each `None` when `faces` is 0 |
|
|
462
|
+
| `AiGeneratedReading` | `image`, `generated` (the probability that a generator made the picture), `confidence`, `method` (`inference`: read from the pixels; `c2pa`: the file's trusted Content Credentials declare it generated or edited by AI), `top_generator` (`name`, `probability` of the likeliest generator when the reading says likely generated; `None` otherwise, and always on a `c2pa` reading), `c2pa_signer` (who signed the file's Content Credentials when they are trusted, else `None`), `c2pa` (`untrusted` when the file carries Content Credentials that validate but are not trusted, which change nothing in the reading; else `None`) |
|
|
463
|
+
| `UnknownReading` | `image` and `raw`: a reading of a booster this version does not know, or one with a field missing or of the wrong type. The decision does not fail |
|
|
464
|
+
|
|
465
|
+
Booster calls are billed from your prepaid balance with the request's tokens, one per
|
|
466
|
+
booster and image read with status `ok`: a booster that timed out or failed is free, and so
|
|
467
|
+
are the boosters of a refused request. The readings' tokens are input tokens at the model's
|
|
468
|
+
price. The price per call is on the
|
|
469
|
+
[pricing page](https://decisionnode.com/docs/pricing#boosters).
|
|
470
|
+
|
|
471
|
+
| Status | `error_type` | Exception |
|
|
472
|
+
|---|---|---|
|
|
473
|
+
| 400 | `api_usage_error`: `boosters` malformed (not a list, more than 4 entries, a booster named twice, an unknown key in `options`); the reason is in `e.reason` | `BadRequestError` |
|
|
474
|
+
| 400 | `booster_unknown`: a name that is not a booster | `BadRequestError` |
|
|
475
|
+
| 400 | `booster_input_mismatch`: a booster that reads images on a request without any, or an `images` entry that is not an image of the request | `BadRequestError` |
|
|
476
|
+
| 403 | `booster_not_enabled`: the booster is turned off for your workspace | `PermissionDeniedError` |
|
|
477
|
+
| 529 | `booster_unavailable`: a booster with `require=True` failed or did not answer in time | `OverloadedError`, retried |
|
|
478
|
+
|
|
479
|
+
Every error of a request that named boosters carries `e.boosters`: what each booster did,
|
|
480
|
+
in the request's order, as booster name to `BoosterStatus` (`status`, `latency_ms`), so you
|
|
481
|
+
can see which required booster failed.
|
|
482
|
+
|
|
483
|
+
```python
|
|
484
|
+
from decisionnode import OverloadedError
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
d = dn.decide(images=images, boosters=[Booster("age", require=True)], questions=questions)
|
|
488
|
+
except OverloadedError as e:
|
|
489
|
+
if e.error_type == "booster_unavailable":
|
|
490
|
+
print(e.boosters["age"].status) # "timeout"
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
## Sessions
|
|
494
|
+
|
|
495
|
+
A session fixes the questions once, then answers every frame you send on a WebSocket,
|
|
496
|
+
newest first: a frame that arrives while another is being answered replaces any frame
|
|
497
|
+
still waiting, so you always get the answer to the latest reading, never a backlog.
|
|
498
|
+
|
|
499
|
+
```python
|
|
500
|
+
from decisionnode import DecisionNode, Reply, FrameError, SessionClosed, choice, truth
|
|
501
|
+
|
|
502
|
+
dn = DecisionNode()
|
|
503
|
+
with dn.open_session(
|
|
504
|
+
instructions="You are the mission supervisor of a survey drone.",
|
|
505
|
+
state={"mission": "Survey the north field"},
|
|
506
|
+
questions={
|
|
507
|
+
"mode": choice("Which flight mode now?", ["continue", "hold", "return", "land"]),
|
|
508
|
+
"abort": truth("Should the mission be aborted now?"),
|
|
509
|
+
},
|
|
510
|
+
window=8,
|
|
511
|
+
) as session:
|
|
512
|
+
session.connect()
|
|
513
|
+
session.send({"battery": 0.31, "wind_mps": 11.4})
|
|
514
|
+
for event in session.replies():
|
|
515
|
+
if isinstance(event, Reply):
|
|
516
|
+
print(event.seq, event["mode"].choice, event.dropped)
|
|
517
|
+
elif isinstance(event, FrameError):
|
|
518
|
+
print("frame", event.seq, event.error_type, event.message)
|
|
519
|
+
else: # SessionClosed, the last event
|
|
520
|
+
print("closed", event.code, event.reopen)
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
| Call | |
|
|
524
|
+
|---|---|
|
|
525
|
+
| `open_session(state, questions, instructions, model, window, ttl_seconds)` | `POST /v1/sessions`. Returns a `Session` with every field of the answer (`session_id`, `model`, `window`, `ttl_seconds`, `expires_at`, `limits`, `questions`, `usage`, `answers` to the questions on the state alone, `safety`) |
|
|
526
|
+
| `Session.connect()` | Opens the stream. Your key goes in the handshake's `Authorization` header, never in the URL; your custom headers go too. The stream goes through the same proxy and uses the same certificates as the HTTP client |
|
|
527
|
+
| `Session.send(frame, image, questions, reset, seq)` | Sends one frame and returns its `seq`, counted from 1 unless you give one. Never waits for the answer. `image` is one `Image` (not a URL), sent inline or by `upload_id` as above |
|
|
528
|
+
| `Session.replies()` | Yields `Reply` and `FrameError` values as they come, then the `SessionClosed` |
|
|
529
|
+
| `Session.next_event(timeout)` | The next event, or `None` after `timeout` seconds |
|
|
530
|
+
| `Session.end()` | Ends the session (`DELETE`). A session that has already ended counts as ended |
|
|
531
|
+
| `Session.close()` | Closes the stream |
|
|
532
|
+
|
|
533
|
+
A `FrameError` is a value, not an exception: the session stays open. `SessionClosed` has
|
|
534
|
+
the close `code` and `reopen`, true when opening a new session with the same arguments is
|
|
535
|
+
the right next step (`1001`, `1006`, `1011`, `4404`, `4408`, `4409`, `4410`). A key that
|
|
536
|
+
already holds its open sessions gets `SessionLimitError` (a `RateLimitError`), which is
|
|
537
|
+
never retried. Pings are answered for you. The async client has `AsyncSession`, with the
|
|
538
|
+
same methods as coroutines and `async for event in session.replies()`.
|
|
539
|
+
|
|
540
|
+
### Supervise a feed
|
|
541
|
+
|
|
542
|
+
```python
|
|
543
|
+
def readings():
|
|
544
|
+
while drone.flying:
|
|
545
|
+
yield {"frame": drone.telemetry()}
|
|
546
|
+
time.sleep(1)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
last_close = dn.supervise({"questions": questions, "state": mission}, readings(), on_reply=print)
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
`supervise(body, feed, on_reply)` keeps one session answering a feed of readings. `body`
|
|
553
|
+
holds the `open_session` arguments, each reading the `send` arguments, and `on_reply` gets
|
|
554
|
+
every `Reply` and `FrameError`. When the session closes on a code that calls for it, a new
|
|
555
|
+
one is opened (at once after the idle and lifetime closes, else after a backoff of 1 to 30
|
|
556
|
+
seconds with jitter); on `1013` it connects to the same session again; on any other code
|
|
557
|
+
it stops and returns that `SessionClosed`. When the feed ends it waits for the replies
|
|
558
|
+
still owed, ends the session and returns the last `SessionClosed` seen, or `None`. With
|
|
559
|
+
the async client the feed may be an async iterator and `on_reply` a coroutine function.
|
|
560
|
+
|
|
561
|
+
## Batches
|
|
562
|
+
|
|
563
|
+
The Batch API answers many decide requests within 24 hours at half the price.
|
|
564
|
+
|
|
565
|
+
```python
|
|
566
|
+
requests = [
|
|
567
|
+
{"custom_id": t.id, "state": t.text, "questions": {"route": choice("Where should this go?", ["billing", "bug"])}}
|
|
568
|
+
for t in tickets
|
|
569
|
+
]
|
|
570
|
+
batch, results = dn.run_batch(requests, poll_interval=60)
|
|
571
|
+
for r in results:
|
|
572
|
+
if r.decision is not None:
|
|
573
|
+
print(r.custom_id, r.decision["route"].choice)
|
|
574
|
+
else:
|
|
575
|
+
print(r.custom_id, r.status, r.error)
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
`run_batch(requests, poll_interval, timeout)` creates a draft, adds the requests, queues
|
|
579
|
+
it, reads it every `poll_interval` seconds (at least 10; the server's `Retry-After` when
|
|
580
|
+
that is longer) until it ends, and returns the ended `Batch` with its results. Past
|
|
581
|
+
`timeout` (default 26 hours) it raises `TimeoutError`; the batch keeps running. If adding
|
|
582
|
+
the requests fails, the draft is cancelled and the error raised.
|
|
583
|
+
|
|
584
|
+
Each request is a `decide` request plus a `custom_id` (1 to 512 characters, unique in the
|
|
585
|
+
batch). Every request is checked before anything is sent; images follow the rules above,
|
|
586
|
+
per line.
|
|
587
|
+
|
|
588
|
+
| Call | |
|
|
589
|
+
|---|---|
|
|
590
|
+
| `batches.create(requests)` | A draft holding these requests (none makes an empty draft), sent as JSON Lines |
|
|
591
|
+
| `batches.add(batch_id, requests)` | Adds requests to a draft, in calls of at most 10 MiB, in order |
|
|
592
|
+
| `batches.finalize(batch_id)` | Queues the draft. Nothing runs or is billed before |
|
|
593
|
+
| `batches.get(batch_id)` | The `Batch`: `id`, `state`, `counters`, the times, `results_url`, `usage`, `error` |
|
|
594
|
+
| `batches.list(offset, limit)` | A `BatchPage`: `batches`, `offset`, `limit`, `total`, `next_offset` |
|
|
595
|
+
| `batches.results(batch_id)` | The `BatchResult` lines of an ended batch, in upload order, read from the network as you iterate |
|
|
596
|
+
| `batches.cancel(batch_id)` | Cancels the lines not started |
|
|
597
|
+
|
|
598
|
+
A `BatchResult` has `custom_id`, `status` (the HTTP status, or `"expired"` or
|
|
599
|
+
`"cancelled"`), `decision` for a `200` line, `error` for an error line (the exception the
|
|
600
|
+
live call would have raised, as a value), and `headers`.
|
|
601
|
+
|
|
602
|
+
`get`, `list`, `results` and `cancel` are retried like every call. `create`, `add` and
|
|
603
|
+
`finalize` change the batch, so they are retried only on `429` and `529`, which the API
|
|
604
|
+
answers before applying anything, and never after a timeout or a lost answer. A finalize
|
|
605
|
+
answered `409` is checked with `get`, and the batch is returned when an earlier finalize
|
|
606
|
+
went through.
|
|
607
|
+
|
|
608
|
+
## Models
|
|
609
|
+
|
|
610
|
+
```python
|
|
611
|
+
for m in dn.models():
|
|
612
|
+
print(m.name, m.release_date, m.limits)
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
Each entry is a `ModelInfo`. `limits` holds every other field the model publishes: the
|
|
616
|
+
token limit, the number grid limits, the upload limits, and the `sessions` and `batch`
|
|
617
|
+
objects.
|
|
618
|
+
|
|
619
|
+
| Model | |
|
|
620
|
+
|---|---|
|
|
621
|
+
| `decisionnode-moe` | The main model, always its current release. The default. |
|
|
622
|
+
| `decisionnode-flash` | Faster and lower cost, always its current release. |
|
|
623
|
+
|
|
624
|
+
Choose per client (`DecisionNode(model="decisionnode-flash")`) or per call
|
|
625
|
+
(`dn.decide(..., model="decisionnode-moe")`). Each model publishes its limits in
|
|
626
|
+
`models()`.
|
|
627
|
+
|
|
628
|
+
`models()` also lists the pinned ids served, `decisionnode-<family>-<version>` (such as
|
|
629
|
+
`decisionnode-moe-<version>`); send one to stay on that release. Every answer's `model` is
|
|
630
|
+
the pinned id that answered. `model` is free text: a pinned id, a fine-tuned name and the
|
|
631
|
+
older ids the API still accepts (`decisionnode-1.0`, `decisionnode-1.0-flash`,
|
|
632
|
+
`decisionnode-latest`, `decisionnode-flash-latest`) are sent as given.
|
|
633
|
+
|
|
634
|
+
### Fallback
|
|
635
|
+
|
|
636
|
+
A request for `decisionnode-moe` that no worker of that model can take at once is
|
|
637
|
+
answered by Flash instead, billed at its price. `d.model` is the model that answered, and
|
|
638
|
+
`d.fallback` names both: the model that answered by its pinned id, and the model asked for
|
|
639
|
+
as it was asked (a family id or an older id as the family id, a pinned id as itself):
|
|
640
|
+
|
|
641
|
+
```python
|
|
642
|
+
d = dn.decide(state=ticket, questions=questions, model="decisionnode-moe")
|
|
643
|
+
if d.fallback is not None:
|
|
644
|
+
d.fallback.served_model # "decisionnode-flash-<version>"
|
|
645
|
+
d.fallback.requested_model # "decisionnode-moe"
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
`fallback=False` turns it off for one request: such a request gets the 529
|
|
649
|
+
`OverloadedError` instead, which is retried like every 529. `decide`, the helpers,
|
|
650
|
+
`decide_many` requests and batch requests take it; left out, the API's default (on)
|
|
651
|
+
applies. Sessions, batch lines and fine-tuned models never fall back.
|
|
652
|
+
|
|
653
|
+
### Fine-tuned models
|
|
654
|
+
|
|
655
|
+
A model your workspace trained in the console is called by its name, by your workspace's
|
|
656
|
+
keys only, and answers like its base: the same question types, answers, limits and
|
|
657
|
+
errors.
|
|
658
|
+
|
|
659
|
+
```python
|
|
660
|
+
d = dn.decide(state=claim, questions=questions, model="decisionnode-moe@acme/fraud")
|
|
661
|
+
d.model # "decisionnode-moe@acme/fraud:3", the version that answered
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
The name is `<base>@<workspace>/<name>`, which means the version deployed most recently,
|
|
665
|
+
or `<base>@<workspace>/<name>:<n>` for version `n`. The client sends it as given; a name
|
|
666
|
+
that does not exist, a version that is not deployed and another workspace's model all
|
|
667
|
+
raise `BadRequestError` (`Unknown model: <name>`). `Decision.model` is always the pinned
|
|
668
|
+
name, so you can log which version answered.
|
|
669
|
+
|
|
670
|
+
`models()` lists your workspace's fine-tuned models beside the catalogue's, each with
|
|
671
|
+
`fine_tuned` (`True`), `base` (the base model), `adapter_version` (the version number) and
|
|
672
|
+
`status` (`deployed`, which answers decisions, or `ready`: trained and not deployed). On
|
|
673
|
+
the catalogue's own entries these four are `None`.
|
|
674
|
+
|
|
675
|
+
## Errors
|
|
676
|
+
|
|
677
|
+
Every error derives from `DecisionNodeError`, with `status`, `error_type`, `request_id`,
|
|
678
|
+
`boosters` (for a request with boosters, what each booster did; an empty mapping otherwise)
|
|
679
|
+
and `reason`: the server's explanation in words, from the `x-decisionnode-error` header,
|
|
680
|
+
for example `question 'q': a score takes up to 10 levels, got 11`. The error's own
|
|
681
|
+
message, `str(e)` (also `e.message`), is the server's `message` exactly (`""` when the
|
|
682
|
+
server sent none, never `None`), or the client's description when no response arrived.
|
|
683
|
+
|
|
684
|
+
| Status | Exception | Extra fields |
|
|
685
|
+
|---|---|---|
|
|
686
|
+
| 400 | `BadRequestError`; `ContextLimitError` when the request is over the model's token limit | |
|
|
687
|
+
| 401 | `AuthenticationError` (also raised before sending when no key is set) | |
|
|
688
|
+
| 402 | `InsufficientCreditError` | |
|
|
689
|
+
| 403 | `RefusalError`: refused under the usage policy | `category`, `safety` |
|
|
690
|
+
| 403, not a refusal | `PermissionDeniedError`: your workspace may not do this, for example use a booster that is turned off for it | |
|
|
691
|
+
| 404 | `NotFoundError` | |
|
|
692
|
+
| 413 | `RequestTooLargeError`: the body is over 25 MiB; never retried (also raised before sending) | |
|
|
693
|
+
| 422 | `ValidationError` | `entries` |
|
|
694
|
+
| 429 | `RateLimitError`; `SessionLimitError` for too many open sessions | `retry_after` (seconds) |
|
|
695
|
+
| 529 | `OverloadedError`, also when a required booster failed (`error_type` `booster_unavailable`) | `retry_after` (seconds) |
|
|
696
|
+
| other 5xx, a 2xx whose body is not JSON, or a helper's own answer missing or malformed | `ServerError` | |
|
|
697
|
+
| 3xx and every other status | `DecisionNodeError` itself, with `status` | |
|
|
698
|
+
| no response | `ConnectionError`, `TimeoutError` | |
|
|
699
|
+
|
|
700
|
+
An error whose body is not JSON (a page from a proxy or the service's edge, or no body at
|
|
701
|
+
all) is classed by its status alone, as in the table: a 400 is a `BadRequestError`, a 403
|
|
702
|
+
a `PermissionDeniedError`, a 422 a `ValidationError` with no `entries`. Its `error_type` is
|
|
703
|
+
`None`, `request_id` and `reason` come from the response headers when it has them, and the
|
|
704
|
+
page is never read: the message is the client's own, `HTTP <status> <text>` (for example
|
|
705
|
+
`HTTP 413 Content Too Large`), followed by ` (request id <id>)` when the response has an
|
|
706
|
+
`x-request-id`. It is retried as any other error of its status: a 408 is, a 413 is not.
|
|
707
|
+
|
|
708
|
+
```python
|
|
709
|
+
from decisionnode import DecisionNodeError, ValidationError
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
dn.decide(state="...", questions={"q": score("Rate it", levels)})
|
|
713
|
+
except ValidationError as e:
|
|
714
|
+
print(e.entries)
|
|
715
|
+
except DecisionNodeError as e:
|
|
716
|
+
print(e.status, e.reason, e.request_id)
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
`ConnectionError` and `TimeoutError` here are the classes of this package, not
|
|
720
|
+
Python's built-ins of the same name.
|
|
721
|
+
|
|
722
|
+
A problem with an argument is found before anything is sent and raises Python's own
|
|
723
|
+
`ValueError`, never a `DecisionNodeError`: an empty question map, a value that cannot be
|
|
724
|
+
sent as JSON, a file that cannot be read, a `base_url` that is not `https`, a `timeout`
|
|
725
|
+
that is not above 0 or a negative `max_retries`.
|
|
726
|
+
|
|
727
|
+
A request the API would refuse by its fixed rules is refused before anything is sent
|
|
728
|
+
too, so the server never sees it. The error is the one the API would answer with (the
|
|
729
|
+
same class, `error_type`, message, and for a `ValidationError` the same `entries`), but
|
|
730
|
+
with no `status` and no `request_id`. The checks run in the API's order:
|
|
731
|
+
|
|
732
|
+
- more than 128 objects and arrays inside each other (`BadRequestError`, "Invalid request.");
|
|
733
|
+
- a body over 25 MiB as it will be sent (`RequestTooLargeError`, `error_type`
|
|
734
|
+
`request_too_large_error`);
|
|
735
|
+
- the shape of each question and of the state (`ValidationError`): a question that is not
|
|
736
|
+
an object or has no `type`, values that must be a string, an object or an array, a
|
|
737
|
+
choice or score without `criteria`, a score without levels, and every number grid rule;
|
|
738
|
+
- the question limits (`BadRequestError`): an unknown type ("Invalid request."), a choice
|
|
739
|
+
without options or with more than 255, a
|
|
740
|
+
score with more than 10 levels, a truth question with neither instructions nor
|
|
741
|
+
criteria, a number question without instructions;
|
|
742
|
+
- every image rule above, an id for each image of at most 64 characters (counted as
|
|
743
|
+
characters, not bytes: "Image 1: its id is longer than 64 characters."), each id used
|
|
744
|
+
once, and for a URL: `https`, at most 2,048 characters, no user information, port 443,
|
|
745
|
+
not a literal private address (`image_url_refused`);
|
|
746
|
+
- for sessions, `window` (1 to 64) and `ttl_seconds` (1 to 3,600) as `ValidationError`, and
|
|
747
|
+
each frame in `Session.send`, which raises `BadRequestError` for a frame the server would
|
|
748
|
+
refuse (an unknown question id, an empty frame, an image by URL...) without writing
|
|
749
|
+
anything or using a `seq`;
|
|
750
|
+
- for batches, the `custom_id` rules, an add without requests and `batches.list` paging.
|
|
751
|
+
|
|
752
|
+
`decide_many` and batches check every request first. After a `models()` call the client
|
|
753
|
+
also checks the limits each model publishes there (a model that serves no sessions or
|
|
754
|
+
answers no number questions, its maximum window, batch size and `custom_id` length),
|
|
755
|
+
for a model named by its name, its alias or its pinned version. Limits counted in tokens
|
|
756
|
+
stay the server's.
|
|
757
|
+
|
|
758
|
+
### Check without sending
|
|
759
|
+
|
|
760
|
+
```python
|
|
761
|
+
dn.check_request("decide", state=ticket, questions=questions, images=images)
|
|
762
|
+
dn.check_request("open_session", questions=questions, window=8)
|
|
763
|
+
dn.check_request("batches.create", requests=requests)
|
|
764
|
+
session.check_frame({"battery": 0.31}, questions=["abort"])
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
`check_request(method, **arguments)` runs every check the call `method` (`decide`,
|
|
768
|
+
`open_session`, `batches.create` or `batches.add`) would run before sending,
|
|
769
|
+
with the same arguments, and raises the same error; it sends nothing, uploads nothing and
|
|
770
|
+
returns `None` when the request would go out. `Session.check_frame(...)` does the same for
|
|
771
|
+
a frame. Neither makes a request, so neither is a coroutine on the async client.
|
|
772
|
+
|
|
773
|
+
A failed storage upload raises `DecisionNodeError` with `error_type` `upload_failed`, the
|
|
774
|
+
storage's `status`, and its error code in `reason`. Storage `PUT`s are retried on 5xx and
|
|
775
|
+
dropped connections like any call.
|
|
776
|
+
|
|
777
|
+
Only a 2xx is a success. Redirects are never followed, also on an `http_client` you
|
|
778
|
+
pass in that is set to follow them: the client turns redirects off on every request it
|
|
779
|
+
sends, so the key is never sent to another URL or over plain HTTP.
|
|
780
|
+
|
|
781
|
+
## Your own HTTP client
|
|
782
|
+
|
|
783
|
+
`http_client` takes an `httpx.Client` (`httpx.AsyncClient` for the async client), for a
|
|
784
|
+
proxy, custom certificates or connection limits. The client checks it when it is made
|
|
785
|
+
and raises `ValueError` unless it can show that the key stays safe:
|
|
786
|
+
|
|
787
|
+
- It must be an `httpx.Client` (or `httpx.AsyncClient`) itself, not a subclass or
|
|
788
|
+
another type: such a client could follow redirects whatever it is told.
|
|
789
|
+
- Its transports, including any in `mounts`, must be httpx's own `HTTPTransport`
|
|
790
|
+
(`AsyncHTTPTransport`) or `MockTransport`. A transport of another type may follow
|
|
791
|
+
redirects itself and is refused.
|
|
792
|
+
- For an `https` base URL, certificate and hostname checks must stay on: `verify=False`,
|
|
793
|
+
or an `ssl.SSLContext` with `check_hostname` off or `verify_mode` other than
|
|
794
|
+
`CERT_REQUIRED`, is refused. A custom CA (`verify=ssl.create_default_context(cafile=...)`)
|
|
795
|
+
is fine.
|
|
796
|
+
|
|
797
|
+
```python
|
|
798
|
+
import httpx
|
|
799
|
+
|
|
800
|
+
dn = DecisionNode(http_client=httpx.Client(proxy="http://proxy.example.com:3128"))
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
The client does not close an `http_client` you pass in.
|
|
804
|
+
|
|
805
|
+
## Retries and timeouts
|
|
806
|
+
|
|
807
|
+
Each attempt times out after 10 seconds (30 for a request whose boosters name `ocr`,
|
|
808
|
+
unless you set a timeout; see Boosters). The timeout covers the whole attempt, from
|
|
809
|
+
sending the request to the last byte of the response body, so a server that answers
|
|
810
|
+
slowly a little at a time cannot hold a call longer than that. A storage `PUT` of an
|
|
811
|
+
image has its own deadline for the whole attempt, `upload_timeout` (60 seconds by
|
|
812
|
+
default), since up to 10 MB may need longer than an answer does. Batch results are read
|
|
813
|
+
from the network line by line as you iterate them, each read bounded by the timeout, so a
|
|
814
|
+
file of any size is read whole. A failed request is retried twice on 408, 429,
|
|
815
|
+
any 5xx (529 included), a timeout or a dropped connection; a request naming `ocr` is not
|
|
816
|
+
retried after a timeout, since the server finishes that attempt. The wait is `retry-after-ms`
|
|
817
|
+
when the server sends it, else `Retry-After` in seconds or as an HTTP date, capped at
|
|
818
|
+
60 seconds either way; without either, an exponential backoff from 0.5 seconds with
|
|
819
|
+
jitter, capped at 8 seconds. A 408 still failing after its retries raises
|
|
820
|
+
`DecisionNodeError` with `status` 408. 400, 401, 402, 403, 404 and
|
|
821
|
+
422 are never retried: the same request would fail again. A decision changes nothing on
|
|
822
|
+
the server, so a retry is always safe.
|
|
823
|
+
|
|
824
|
+
```python
|
|
825
|
+
dn = DecisionNode(timeout=5.0, max_retries=4)
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
## Safety
|
|
829
|
+
|
|
830
|
+
On the hosted API every decision carries the result of the safety check:
|
|
831
|
+
|
|
832
|
+
```python
|
|
833
|
+
d.safety # Safety(checked=True, harm=0.031, self_harm=0.004, flagged=[], ...)
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
`flagged` lists the categories above your key's thresholds. When the check did not run,
|
|
837
|
+
`checked` is false and `reason` says why. A self-hosted server sends nothing, and
|
|
838
|
+
`safety` is `None`. A request the policy refuses raises `RefusalError` and is never
|
|
839
|
+
retried.
|
|
840
|
+
|
|
841
|
+
### The safety result in the body
|
|
842
|
+
|
|
843
|
+
The result always arrives in the `x-decisionnode-safety` response header. To also get it
|
|
844
|
+
in the response body, send the request header `x-decisionnode-safety-body: true`. Pass
|
|
845
|
+
it through `headers`, and it goes with every request of that client:
|
|
846
|
+
|
|
847
|
+
```python
|
|
848
|
+
dn = DecisionNode(headers={"x-decisionnode-safety-body": "true"})
|
|
849
|
+
|
|
850
|
+
d = dn.decide(state="...", questions={"q": truth("Is this spam?")})
|
|
851
|
+
d.raw["safety"] # {"checked": True, "harm": 0.031, "self_harm": 0.004, "flagged": []}
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
`d.safety` is the same with or without the header; the header only adds the field to
|
|
855
|
+
`d.raw`.
|
|
856
|
+
|
|
857
|
+
## Configuration
|
|
858
|
+
|
|
859
|
+
| Argument | Environment variable | Default |
|
|
860
|
+
|---|---|---|
|
|
861
|
+
| `api_key` | `DECISIONNODE_API_KEY` | none: a call without a key fails before sending |
|
|
862
|
+
| `base_url` | `DECISIONNODE_BASE_URL` | `https://api.decisionnode.com` |
|
|
863
|
+
| `model` | `DECISIONNODE_MODEL` | `decisionnode-moe` |
|
|
864
|
+
| `timeout` | | `10.0` seconds for one whole attempt; must be above 0 |
|
|
865
|
+
| `max_retries` | | `2`; must be 0 or more |
|
|
866
|
+
| `headers` | | extra headers sent on every request |
|
|
867
|
+
| `http_client` | | an `httpx.Client` (`httpx.AsyncClient` for the async client), for proxies or custom certificates; see "Your own HTTP client" |
|
|
868
|
+
| `inline_threshold` | | `524288` bytes (`DEFAULT_INLINE_THRESHOLD`); local images of a request below it go inline, at or above it they are uploaded |
|
|
869
|
+
| `upload_timeout` | | `60.0` seconds for one storage `PUT` attempt of an image; must be above 0 |
|
|
870
|
+
| `http2` | | `False`; `True` uses HTTP/2 for the API connection (`pip install "bynn-decisionnode[http2]"`) |
|
|
871
|
+
|
|
872
|
+
An argument wins over the environment, and the environment over the default. An empty
|
|
873
|
+
string (`api_key=""`, also `model=""` on a call) counts as not given. A client
|
|
874
|
+
is safe to share between threads and tasks; close it with `dn.close()`
|
|
875
|
+
(`await dn.close()`) or use it as a context manager.
|
|
876
|
+
|
|
877
|
+
Create one client and reuse it: it keeps its connections open between calls (up to 100,
|
|
878
|
+
each kept for 30 seconds idle), so a call after the first needs no new connection or TLS
|
|
879
|
+
handshake, and it remembers its image uploads.
|
|
880
|
+
|
|
881
|
+
The base URL must be `https`, so the key never travels unencrypted. Plain `http` is
|
|
882
|
+
accepted only for `localhost`, `127.0.0.1` and `::1`, for a server on the same machine;
|
|
883
|
+
any other `http` URL, from the argument or the environment, raises `ValueError` when the
|
|
884
|
+
client is made.
|
|
885
|
+
|
|
886
|
+
## Raw requests
|
|
887
|
+
|
|
888
|
+
`decide_raw(body)` sends a request body exactly as given and returns the parsed
|
|
889
|
+
response body, for an API field this version of the SDK does not know yet:
|
|
890
|
+
|
|
891
|
+
```python
|
|
892
|
+
dn.decide_raw(
|
|
893
|
+
{
|
|
894
|
+
"model": "decisionnode-moe",
|
|
895
|
+
"state": "",
|
|
896
|
+
"questions": {"q": {"type": "truth", "instructions": "Is hotdog a sandwich?"}},
|
|
897
|
+
}
|
|
898
|
+
)
|
|
899
|
+
```
|
|
900
|
+
|
|
901
|
+
## Logging
|
|
902
|
+
|
|
903
|
+
The SDK sends no telemetry; the only identifying header it adds is
|
|
904
|
+
`User-Agent: decisionnode-python/<version>`. It logs to the `decisionnode` logger at
|
|
905
|
+
debug level, with the method, path, status, request id and timing of each attempt,
|
|
906
|
+
never the state, questions, images or key (a storage URL is logged without its
|
|
907
|
+
signature):
|
|
908
|
+
|
|
909
|
+
```python
|
|
910
|
+
import logging
|
|
911
|
+
|
|
912
|
+
logging.getLogger("decisionnode").setLevel(logging.DEBUG)
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
## Development
|
|
916
|
+
|
|
917
|
+
```sh
|
|
918
|
+
uv sync
|
|
919
|
+
uv run pytest
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
The tests include the DecisionNode SDK conformance suite
|
|
923
|
+
(`Bynn-Intelligence/decision-node-conformance`), run with both the sync and the async
|
|
924
|
+
client. Set `DECISIONNODE_CONFORMANCE_DIR` to a checkout of it, or check it out at
|
|
925
|
+
`conformance/` in this repository; without it the conformance tests fail rather than
|
|
926
|
+
skip. The tests pass suite 1.13; CI checks the suite out at the commit pinned in
|
|
927
|
+
`CONFORMANCE_REF` in `.github/workflows/ci.yml`, with every action pinned to a commit SHA
|
|
928
|
+
and no checkout keeping its credentials.
|
|
929
|
+
|
|
930
|
+
```sh
|
|
931
|
+
DECISIONNODE_CONFORMANCE_DIR=/path/to/decision-node-conformance uv run pytest
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
This package implements version 1.2 of the specification shared by every official
|
|
935
|
+
DecisionNode SDK, exported as `decisionnode.SPEC_VERSION`.
|
|
936
|
+
|
|
937
|
+
## License
|
|
938
|
+
|
|
939
|
+
MIT. See [LICENSE](LICENSE).
|