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,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).