bynn-decisionnode 0.4.1

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.
data/README.md ADDED
@@ -0,0 +1,908 @@
1
+ # DecisionNode Ruby SDK
2
+
3
+ [DecisionNode](https://decisionnode.com) is a decision engine: send it text, JSON or images with a question, and it answers with calibrated probabilities.
4
+
5
+ Ruby client for the [DecisionNode](https://decisionnode.com) API: ask yes-or-no, choice, score and number questions about any state and get calibrated answers back, one request at a time, over a live session, or as a batch.
6
+
7
+ Ruby 3.2 or later. One runtime dependency, `websocket-driver`, used by sessions only and loaded on the first one.
8
+
9
+ ## Install
10
+
11
+ Not on RubyGems yet: the first release publishes it there. Until then, install it from GitHub:
12
+
13
+ ```sh
14
+ bundle add bynn-decisionnode --git https://github.com/Bynn-Intelligence/decision-node-ruby
15
+ ```
16
+
17
+ Once it is on RubyGems:
18
+
19
+ ```sh
20
+ bundle add bynn-decisionnode
21
+ ```
22
+
23
+ or
24
+
25
+ ```sh
26
+ gem install bynn-decisionnode
27
+ ```
28
+
29
+ ## Set your key
30
+
31
+ ```sh
32
+ export DECISIONNODE_API_KEY="dn_..."
33
+ ```
34
+
35
+ A key's kind decides what it may call: Full access for your servers, Decisions only for a device or a service that only decides, Manage device keys for the server that hands out device keys, and Publishable for a web page or an app. See [API keys](https://decisionnode.com/docs/concepts/keys).
36
+
37
+ ## Quickstart
38
+
39
+ ```ruby
40
+ require "decisionnode"
41
+
42
+ dn = DecisionNode.new # reads DECISIONNODE_API_KEY
43
+
44
+ d = dn.decide(
45
+ state: { ticket: "I was charged twice and nobody has replied for 3 days." },
46
+ questions: {
47
+ route: DecisionNode.choice("Where should this go?", { billing: "money", bug: "broken", account: "login" }),
48
+ urgency: DecisionNode.score("How urgent is this?", %w[routine today urgent critical]),
49
+ escalate: DecisionNode.truth("Escalate to a human now?")
50
+ }
51
+ )
52
+
53
+ d["route"].choice # => "billing"
54
+ d["route"].probabilities # => {"billing" => 0.87, "bug" => 0.08, "account" => 0.05}
55
+ d["urgency"].score # => 2.31
56
+ d["escalate"].truth # => 0.94
57
+ ```
58
+
59
+ The values you read are the values the API sent: nothing is rounded, renormalised or thresholded. What 0.94 means for your application is your code's decision.
60
+
61
+ ## Questions
62
+
63
+ Six builders return a question's wire form as a plain Hash:
64
+
65
+ | Builder | Asks for |
66
+ |---|---|
67
+ | `DecisionNode.truth(instructions, if_true: nil, if_false: nil)` | The probability that something is true. `if_true` and `if_false` describe each side and are optional. |
68
+ | `DecisionNode.choice(instructions, options)` | One option of a set. `options` is a Hash of name to description, or an Array of names. |
69
+ | `DecisionNode.score(instructions, levels)` | A place on an ordered scale. `levels` is an Array, listed from low to high. |
70
+ | `DecisionNode.number(instructions, min: nil, max: nil, step: nil)` | A value on a grid from `min` to `max` in steps of `step`. A bound left out is left out of the request, so the server's defaults apply: 0, 255 and 1. |
71
+ | `DecisionNode.point(instructions, image: nil, units: nil)` | One spot in an image (see "Points and boxes"). |
72
+ | `DecisionNode.box(instructions, image: nil, units: nil)` | A rectangle in an image (see "Points and boxes"). |
73
+
74
+ `include DecisionNode::Questions` makes them available as `truth(...)`, `choice(...)`, `score(...)`, `number(...)`, `point(...)` and `box(...)`.
75
+
76
+ `state`, instructions, descriptions and levels take a String or any JSON value. A question Hash copied from the API documentation works wherever a builder does:
77
+
78
+ ```ruby
79
+ dn.decide(
80
+ state: "Customer: refund me.",
81
+ questions: { "refund" => { "type" => "truth", "instructions" => "Is this a refund request?" } }
82
+ )
83
+ ```
84
+
85
+ ```ruby
86
+ d = dn.decide(
87
+ state: "Drone frame over row B4 of the north car park.",
88
+ images: [DecisionNode::Image.new("frame", url: "https://files.example.com/frames/b4.jpg")],
89
+ questions: { cars: DecisionNode.number("How many cars are visible in the image?", min: 0, max: 50) }
90
+ )
91
+ d["cars"].number # => 7
92
+ d["cars"].expected # => 7.02
93
+ d["cars"].probabilities # => {"6" => 0.105, "7" => 0.783, "8" => 0.098}
94
+ ```
95
+
96
+ ## The client
97
+
98
+ ```ruby
99
+ dn = DecisionNode.new(
100
+ api_key: "dn_...", # else DECISIONNODE_API_KEY
101
+ base_url: "https://api.decisionnode.com", # else DECISIONNODE_BASE_URL
102
+ model: "decisionnode-moe", # else DECISIONNODE_MODEL
103
+ timeout: 10, # seconds for a whole attempt
104
+ max_retries: 2,
105
+ headers: { "x-team" => "support-tools" }, # sent with every API request
106
+ inline_threshold: 524_288, # bytes; see "Images"
107
+ upload_timeout: 60, # seconds for one image PUT to storage
108
+ logger: nil # a Logger for debug lines
109
+ )
110
+ ```
111
+
112
+ Every argument is optional. An explicit argument wins over the environment, which wins over the default. An empty string for `api_key`, `base_url` or `model` counts as not given, here and for `model:` on a call. A client is safe to share between threads and keeps its connections alive, so repeated calls skip the TCP and TLS handshakes.
113
+
114
+ The client checks its arguments when it is made and raises `ArgumentError` for a bad one:
115
+
116
+ - `base_url` (from the argument or `DECISIONNODE_BASE_URL`) must use `https`. Plain `http` is accepted only for `localhost`, `127.0.0.1` and `::1`, so an API key never travels unencrypted.
117
+ - `timeout` must be above 0 seconds.
118
+ - `max_retries` must be a whole number, 0 or more.
119
+ - `inline_threshold` must be a whole number of bytes, 0 or more.
120
+ - `upload_timeout` must be above 0 seconds.
121
+
122
+ | Method | Returns |
123
+ |---|---|
124
+ | `decide(state:, questions:, images: nil, videos: nil, model: nil, boosters: nil, fallback: nil)` | A `DecisionNode::Decision` |
125
+ | `decide_many(requests, concurrency: 8)` | One `DecisionNode::DecisionResult` per request, in input order: a `Decision`, or the `DecisionNodeError` that request ended with |
126
+ | `check(state:, instructions:, if_true: nil, if_false: nil, images: nil, videos: nil, model: nil, boosters: nil, fallback: nil)` | A `TruthAnswer` |
127
+ | `classify(state:, instructions:, options:, images: nil, videos: nil, model: nil, boosters: nil, fallback: nil)` | A `ChoiceAnswer` |
128
+ | `score(state:, instructions:, levels:, images: nil, videos: nil, model: nil, boosters: nil, fallback: nil)` | A `ScoreAnswer` |
129
+ | `number(state:, instructions:, min: nil, max: nil, step: nil, images: nil, videos: nil, model: nil, boosters: nil, fallback: nil)` | A `NumberAnswer` |
130
+ | `gate(action:, context: nil, images: nil, videos: nil, model: nil, boosters: nil, fallback: nil)` | A `Decision` with the answers `risk` (score), `touches_production` (truth) and `verdict` (choice of `allow`, `confirm`, `block`) |
131
+ | `models` | An Array of `DecisionNode::ModelInfo` |
132
+ | `upload(images)` | One upload id per image (see "Images") |
133
+ | `open_session(questions:, state: nil, instructions: nil, model: nil, window: nil, ttl_seconds: nil)` | A `DecisionNode::Session` (see "Sessions") |
134
+ | `supervise(body, feed, on_reply)` | The last `SessionClosed`, or nil (see "Sessions") |
135
+ | `batches` | The Batch API (see "Batches") |
136
+ | `run_batch(requests, poll_interval: 60, timeout: 93_600)` | `[batch, results]` (see "Batches") |
137
+ | `decide_raw(body)` | The response body as a Hash. Sends `body` exactly as given, for API fields this version does not know yet. |
138
+
139
+ `check`, `classify`, `score` and `number` promise a typed answer, so when their answer is missing or malformed they raise `ServerError` (with the response's `status`, `request_id`, and `reason` from `x-decisionnode-error`). `decide` returns such an answer as an `UnknownAnswer` instead.
140
+
141
+ `gate` sends the state `{"action" => action, "context" => context}` and three questions with fixed wording:
142
+
143
+ | Id | Type | Instructions | Options or levels |
144
+ |---|---|---|---|
145
+ | `risk` | score | How much harm could this action cause if it is wrong? | `none`, `low`, `moderate`, `high`, `severe` |
146
+ | `touches_production` | truth | Does this action change a production system, production data or anything customers use? | |
147
+ | `verdict` | choice | Should this action run as it is, wait for a person to confirm it, or be blocked? | `allow` "safe to run without asking", `confirm` "a person should confirm it first", `block` "should not run" |
148
+
149
+ ```ruby
150
+ dn.check(state: "Customer: refund me.", instructions: "Is this a refund request?").truth
151
+ # => 0.97
152
+
153
+ dn.classify(state: ticket, instructions: "Which team?", options: %w[billing bug account]).choice
154
+ # => "billing"
155
+
156
+ results = dn.decide_many(tickets.map { |t| { state: t, questions: { route: route_question } } }, concurrency: 4)
157
+ results.each do |r|
158
+ r.is_a?(DecisionNode::Decision) ? puts(r["route"].choice) : warn(r.message)
159
+ end
160
+ ```
161
+
162
+ `decide_many` checks every request before it sends any: a `concurrency` below 1 or a request `decide` would refuse as an argument raises `ArgumentError`, and a request the API would refuse raises its `BadRequestError` or `ValidationError` (see "Checked before sending"). Either way nothing is sent.
163
+
164
+ ## Answers
165
+
166
+ | Class | Fields |
167
+ |---|---|
168
+ | `Decision` | `answers` (by question id, also `decision["id"]`), `model` (the resolved version), `usage.input_tokens`, `usage.output_tokens`, `usage.video_frames`, `usage.video_seconds`, `usage.booster_tokens`, `usage.boosters`, `usage.cost`, `evidence` (see "Boosters"), `fallback` (see "Fallback"), `warnings`, `request_id`, `latency_ms`, `safety`, `raw` |
169
+ | `TruthAnswer` | `truth` |
170
+ | `ChoiceAnswer` | `choice`, `confidence`, `probabilities` (by option name, exactly as sent) |
171
+ | `ScoreAnswer` | `score`, `confidence`, `probabilities` (by level index, an Integer), `legend` (level index to the level as sent) |
172
+ | `NumberAnswer` | `number` (the most probable value of the grid), `expected` (the probability-weighted mean), `confidence` (the probability of `number`), `probabilities` (by the value exactly as the server wrote it, `"7"`, `"3.5"`) |
173
+ | `PointAnswer` | `x`, `y`, `present`, `confidence`, `image`, `width`, `height` (see "Points and boxes") |
174
+ | `BoxAnswer` | `x1`, `y1`, `x2`, `y2`, `present`, `confidence`, `image`, `width`, `height` (see "Points and boxes") |
175
+ | `ModelInfo` | `name`, `description`, `release_date`, `limits` (every other field of the entry, its prices included), the prices as sent, each `nil` when the entry states none (`price_per_million_input`, `price_per_million_output`, `price_per_million_image` and `price_per_million_video`, in USD per million tokens, and `batch_price_factor`, which a fine-tuned model does not state), and `sessions` and `batch`, the session and Batch API limits; `limit("sessions", "max_per_key")` reads one. A fine-tuned model's entry also has `fine_tuned`, `base`, `adapter_version` and `status` (see "Fine-tuned models") |
176
+
177
+ Answers are read by question id. An answer of a type this version does not know is returned as a `DecisionNode::UnknownAnswer` with its `type` and `raw` fields, so the rest of the decision still parses. So is an answer of a known type with a field missing or of the wrong type (a `truth` answer without a number in `truth`, for example): the decision does not fail. All answer classes derive from `DecisionNode::Answer`.
178
+
179
+ A token count or `latency_ms` the server did not send is `nil`, never 0. So are `usage.video_frames` and `usage.video_seconds` for a request without videos (`warnings` is then an empty Array), and `evidence`, `usage.booster_tokens` and `usage.boosters` for a request without boosters. `fallback` is `nil` on an ordinary answer.
180
+
181
+ `usage.cost` is what the answer cost, a `DecisionNode::Cost` with `usd`, `model_usd` and `boosters_usd` in USD and `free_tier` (true when the free tier paid for it). It is read as sent, never summed by the SDK, and `nil` when the response has none. A batch result's decision and a session frame's reply carry it too.
182
+
183
+ ## Images
184
+
185
+ Send an image by URL when you can. It is the fastest form: nothing is uploaded, the request stays small, and the API fetches the image while it reads the request.
186
+
187
+ ```ruby
188
+ dn.decide(
189
+ state: { claim: { total: "740.00" } },
190
+ images: [
191
+ DecisionNode::Image.new("scan", url: "https://files.example.com/claims/4411/scan.webp"), # recommended
192
+ DecisionNode::Image.new("receipt", path: "receipt.png"),
193
+ DecisionNode::Image.new("photo", data: jpeg_bytes), # a String of bytes, base64 text or an IO
194
+ DecisionNode::Image.new("again", upload_id: "upl_...") # an earlier upload, for an hour
195
+ ],
196
+ questions: { matches: DecisionNode.truth("Does the receipt total equal the claim?") }
197
+ )
198
+ ```
199
+
200
+ | Input | What the SDK sends |
201
+ |---|---|
202
+ | `url:` | The URL as given, after a local check: `https`, at most 2,048 characters. The SDK never fetches it. |
203
+ | `path:`, `data:`, `base64:` | The bytes, read when the `Image` is made and checked locally, then inline as base64 when all the local images of the request total under `inline_threshold` (`DecisionNode::DEFAULT_INLINE_THRESHOLD`, 524,288 bytes), otherwise uploaded once and sent by upload id. |
204
+ | Bytes uploaded before | The cached upload id, whatever their size, until 5 minutes before the upload expires. |
205
+ | `upload_id:` | The upload id as given. |
206
+
207
+ An upload is one `POST /v1/uploads` for the request's images, then one `PUT` per image, in parallel, straight to storage with exactly the headers the API returned: never your API key, never your custom headers, and no redirect followed. Concurrent calls with the same bytes (`decide_many` over one document, for example) share one upload. Each `PUT` attempt has its own timeout, `upload_timeout` (60 seconds by default), apart from the API's `timeout`, so a 10 MB image on a slow link is not cut off at 10 seconds. The cache lives in the client's memory (at most 256 entries, keyed by the SHA-256 of the bytes and the media type) and is never written to disk. If the API answers that an upload the SDK placed has expired or been used up, the SDK uploads it again and sends the request once more. `upload(images)` uploads whatever the size and returns the upload ids, for use in later requests.
208
+
209
+ Every local image is checked before any request: its format from the first bytes (JPEG, PNG or WebP; a `media_type:` you give must match them), at most 10,485,760 bytes, and at most 40,000,000 pixels, read from the image header without decoding it. A request takes up to 16 images (URLs, inline and uploaded together), with unique ids; one upload call takes all of them. A 17th raises the API's `ValidationError`, a `too_long` entry on the images, before anything is sent. Many large images can still pass the model's token limit, which the API answers with a `ContextLimitError`. No image is ever resized or re-encoded. A file that cannot be read raises `ArgumentError` where the `Image` is made.
210
+
211
+ A request body may be at most 26,214,400 bytes (25 MiB). Inline images and video frames count toward it; uploaded images and URLs count only by their id or URL. The SDK measures the body as it will send it before anything is sent or uploaded, and raises `RequestTooLargeError` when it is over. Local images at or over the inline threshold are uploaded, so a request with images stays far under the limit; a base64 image in a wire-form Hash is sent as given and counts in full.
212
+
213
+ ### Referring to images and videos
214
+
215
+ There are two ways to say which image a question is about:
216
+
217
+ 1. **By id (recommended).** Give each image a short, meaningful id and use it in the instructions, in backticks: "Does the total on `receipt` match the amount in the claim?"
218
+ 2. **By number.** Images are numbered from 1 in the order of `images:`: "image 2", "the second image", "Picture 2" and "the last image" all work.
219
+
220
+ The model sees each image as `Picture 1 (receipt):`, `Picture 2 (claim_photo):`, so both ways point to the same image. Prefer ids: they stay right when images are added or reordered. Use names, not digits, as ids (`front`, not `2`). Videos work the same way, by id or by number ("video 1"), numbered separately in the order of `videos:`. The `image` of a point or box question takes the id.
221
+
222
+ ```ruby
223
+ dn.decide(
224
+ state: "Expense claim 4411: 740.00 EUR.",
225
+ images: [
226
+ DecisionNode::Image.new("receipt", url: "https://files.example.com/claims/4411/receipt.jpg"),
227
+ DecisionNode::Image.new("claim_photo", url: "https://files.example.com/claims/4411/photo.jpg")
228
+ ],
229
+ questions: { matches: DecisionNode.truth("Does the total on `receipt` match the amount in the claim?") }
230
+ )
231
+ ```
232
+
233
+ ## Points and boxes
234
+
235
+ A `point` question finds one spot in an image and a `box` question a rectangle around something, so the answer can be drawn, cropped or handed to a robot.
236
+
237
+ ```ruby
238
+ d = dn.decide(
239
+ images: [DecisionNode::Image.new("belt", url: "https://files.example.com/line-3/cam-2/0917.jpg")],
240
+ questions: {
241
+ pick: DecisionNode.point("Point to the yellow package closest to the robot arm."),
242
+ damage: DecisionNode.box("Draw a box around the damaged package.", image: "belt"),
243
+ any_damage: DecisionNode.truth("Is any package on the belt damaged?")
244
+ }
245
+ )
246
+ d["pick"].x # => 812
247
+ d["pick"].y # => 455
248
+ d["damage"].x1 # => 300 (top-left corner x1, y1; bottom-right x2, y2)
249
+ d["damage"].present # => 0.95
250
+ d["any_damage"].truth # => 0.93
251
+ ```
252
+
253
+ | Argument | Meaning |
254
+ |---|---|
255
+ | `image:` | The id of the image to look in, a string. Needed when the request has two or more images; with one, that one is used. A reference by number ("Picture 2") goes in the instructions. |
256
+ | `units:` | `"pixels"` (the default): integers in pixels of the image as sent, after its EXIF orientation. `"normalized"`: fractions of the width and the height from 0 to 1. |
257
+
258
+ | Field | Meaning |
259
+ |---|---|
260
+ | `x`, `y` (point) | The spot, from the top-left corner of the image, `x` to the right and `y` down. |
261
+ | `x1`, `y1`, `x2`, `y2` (box) | The top-left and bottom-right corners, in the same units. |
262
+ | `present` | The probability that what the instructions describe is in the image at all. Under a low value the place is only the best guess for something that is probably absent. |
263
+ | `confidence` | The probability that the place is right if the thing is there. |
264
+ | `image`, `width`, `height` | The image looked in and its size in pixels. |
265
+
266
+ Every number is as the API sent it. Points and boxes go with `decide`, `decide_many` and batch requests. A request with a point or box question needs an image, and `image:` must name one of the request's images; these faults raise a `ValidationError` before anything is sent (see "Checked before sending").
267
+
268
+ ## Videos
269
+
270
+ A video is sent as frames you already hold, each with its time in seconds: only the frames go to the API, never a video file, a video URL or sound. Every question sees every video, the images and the state.
271
+
272
+ ```ruby
273
+ # JPEG frames from a camera, two a second
274
+ frames = camera_jpegs.each_with_index.map { |bytes, i| DecisionNode::VideoFrame.new(30 + (i * 0.5), data: bytes) }
275
+
276
+ d = dn.decide(
277
+ state: "Warehouse camera 4, loading dock.",
278
+ videos: [DecisionNode::Video.new("dock", frames: frames)],
279
+ questions: { fall: DecisionNode.truth("Does a person fall in the video?") }
280
+ )
281
+ d["fall"].truth # => 0.12
282
+ d.usage.video_frames # the frames the API read
283
+ d.usage.video_seconds # the seconds of video they cover
284
+ d.warnings # set when the API read a video at a lower rate or resolution
285
+ ```
286
+
287
+ | Argument | Meaning |
288
+ |---|---|
289
+ | `VideoFrame.new(t, data:)`, `VideoFrame.new(t, path:)` | A frame's time in seconds (0 or more, larger than the frame before it) and its JPEG, PNG or WebP bytes; the media type comes from the bytes. |
290
+ | `VideoFrame.new(t, base64:, media_type:)` | The same from base64 text, sent as given, with its media type. |
291
+ | `Video.new(id, frames:, fps: nil, start: nil, end: nil)` | Up to 4 videos a request. `id` is unique across the request's images and videos. `fps` caps the rate the API reads (above 0, at most 4); `start` and `end` choose the window on the frames' own times. Each is sent only when given. |
292
+
293
+ Videos go with `decide`, the helpers, `decide_many` and batch requests; sessions take none. Frames are always sent inline as base64: they are never uploaded and never counted toward `inline_threshold`, and no frame is resized or re-encoded. A Hash in the API's wire form works wherever a `Video` or a `VideoFrame` does.
294
+
295
+ The SDK checks a video's form before sending, in the API's order and words: an id, ids unique across images and videos, a non-empty frame list, each frame's time a number of at least 0 and larger than the one before, a JPEG, PNG or WebP media type, `fps` above 0 and at most 4, `start` and `end` at least 0, and `end` after `start`. A fault raises `BadRequestError` with `error_type` `api_usage_error`. More than 4 videos raises the API's `ValidationError`, a `too_long` entry on the videos. How many frames a request may hold, a frame's bytes and size, and whether the window holds a frame are the API's to judge.
296
+
297
+ To turn a video file into frames, use the `dn` command line: `dn truth "Does a person fall?" --video dock.mp4` samples the file on your machine and sends only the frames.
298
+
299
+ ## Boosters
300
+
301
+ A booster is a specialist model that reads the request's images before the decision. Its reading is added to what the model sees as evidence and comes back in the response. The model still answers every question: a booster never answers or adds one, and a request without boosters is answered exactly as before.
302
+
303
+ | Booster | Reads | Tells the model |
304
+ |---|---|---|
305
+ | `age` | A picture of a person | How old the person is, with a range and a confidence (the largest face is read) |
306
+ | `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 |
307
+ | `face_detection` | Any picture | How many faces the picture shows and where, a box and a confidence for each |
308
+ | `face_recognition` | A picture of a person | Which faces match the collection the request names (see Collections): a box, a score and the matched item's reference for each |
309
+ | `ocr` | Any picture | The picture's text, whole, or what the booster's `prompt` asks for |
310
+ | `bynn_depth` (Depth Vision) | Any picture | How far away things are, in meters: the nearest obstacle straight ahead, seven directions from left to right, and a grid of the picture |
311
+ | `bynn_segment` (Segment Vision) | Any picture | Where the things you name in words are: each one's outline, box and centre, and with depth on, its distance and size in meters |
312
+ | `knowledge` (Knowledge) | The request's state and questions, never a picture | The passages of your own documents that apply to the request, as facts and rules to apply (see Knowledge) |
313
+
314
+ ```ruby
315
+ d = dn.decide(
316
+ images: [
317
+ DecisionNode::Image.new("a1b2", path: "selfie.jpg"),
318
+ DecisionNode::Image.new("c3d4", url: "https://files.example.com/c3d4.jpg")
319
+ ],
320
+ boosters: %w[age ai_generated],
321
+ questions: {
322
+ adult: DecisionNode.truth("Is the person in the first picture an adult?"),
323
+ synthetic: DecisionNode.truth("Is the second picture AI-generated?")
324
+ }
325
+ )
326
+
327
+ d["adult"].truth # => 0.97
328
+ age = d.evidence["age"] # a DecisionNode::Evidence
329
+ age.status # => "ok"
330
+ age.results.first.age_years # => 27, with .low, .high, .confidence and .faces
331
+ d.evidence["ai_generated"].results.last.generated # => 0.93
332
+ d.usage.booster_tokens # the readings' share of input_tokens
333
+ d.usage.boosters # the booster calls billed
334
+ ```
335
+
336
+ `boosters:` takes booster names, up to 4, each booster at most once, sent in the order given. A booster that needs a setting is a `DecisionNode::Booster`. Here `ai_generated` reads only the second picture, and the request fails if it cannot:
337
+
338
+ ```ruby
339
+ d = dn.decide(
340
+ images: images,
341
+ boosters: ["age", DecisionNode::Booster.new("ai_generated", images: ["c3d4"], require: true)],
342
+ questions: questions
343
+ )
344
+ ```
345
+
346
+ `DecisionNode::Booster.new(name, images: nil, require: nil, options: nil)` is sent as `{"name": "ai_generated", "images": ["c3d4"], "require": true}`, each argument only when given; a Hash in that form works too. A `Booster` with no argument set is sent as its name, so the request stays the plain list:
347
+
348
+ | Argument | Meaning |
349
+ |---|---|
350
+ | `images` | The ids of the request's images the booster reads. Left out: every image of the request. |
351
+ | `require` | `true`: a booster that fails or does not answer in time fails the request with the 529 `booster_unavailable`, which is retried like every 529. Left out or `false`: the decision runs without that reading, and `evidence` says why. |
352
+ | `options` | The booster's settings, a Hash, for boosters that have any. The API checks them. |
353
+
354
+ | Booster | `options` |
355
+ |---|---|
356
+ | `face_recognition` | `collection` (required): `celebrities`, or the id of one of your collections; `threshold`, 0 to 1 (0.5): a face scored below it is no match |
357
+ | `ocr` | `prompt`: the model's instruction, at most 2,000 characters; left out, all the visible text |
358
+ | `bynn_depth` | `grid_size`: the grid's cells per side, 2 to 16 (8) |
359
+ | `bynn_segment` | `prompt` (required): what to find, one phrase as a String or 1 to 4 phrases as an Array, each at most 120 characters; `max_instances`, 1 to 16 (8); `min_score`, 0 to 1 (0.25); `polygon_points`, the points of each outline, 3 to 64 (24); `depth`, distances and sizes in meters (`true`) |
360
+ | `knowledge` | `collection` (required): a knowledge base's id; `query`, `scope`, `match`, `filter`, `top_k`, `max_tokens`, `pinned`, `min_score`, `include_text` (see Knowledge). `DecisionNode.knowledge(collection, ...)` builds it |
361
+
362
+ ```ruby
363
+ d = dn.decide(
364
+ images: [DecisionNode::Image.new("cam", url: "https://files.example.com/drone/frame-0412.jpg")],
365
+ boosters: [
366
+ DecisionNode::Booster.new("bynn_depth", options: { "grid_size" => 8 }),
367
+ DecisionNode::Booster.new("bynn_segment", options: { "prompt" => ["landing pad", "person"] })
368
+ ],
369
+ questions: { clear: DecisionNode.truth("Is the landing pad clear of people?") }
370
+ )
371
+
372
+ depth = d.evidence["bynn_depth"].results.first # a DecisionNode::DepthReading
373
+ [depth.ahead, depth.directions[3], depth.ground] # => [3.21, 2.81, true]
374
+ d.evidence["bynn_segment"].results.first.instances.each do |found|
375
+ puts [found.label, found.score, found.centroid, found.depth&.median].inspect
376
+ end
377
+ ```
378
+
379
+ `bynn_segment`'s `prompt` as a String is always one phrase; send several as an Array. Descriptive phrases match better than short names: "purple octagonal frame on a pole" finds what "purple gate" may not. Each picture it reads is one booster call, its depth included. Depth Vision's and Segment Vision's distances are estimates from one picture, for decisions and planning, never a safety stop: a robot or a drone uses stereo cameras or LiDAR for that.
380
+
381
+ Boosters go with `decide`, the helpers, `decide_many` and batch requests; sessions take none. They read images in every form: a URL, inline bytes or an upload. Without `boosters:` (or with an empty Array) the request has no `boosters` field at all. Nothing about boosters is checked before sending: the API answers a malformed list, an unknown name and a booster turned off before any booster runs and before any charge.
382
+
383
+ `d.evidence` holds one `DecisionNode::Evidence` per booster the request named, by name in the request's order, and is `nil` when it named none:
384
+
385
+ | Class | Fields |
386
+ |---|---|
387
+ | `Evidence` | `version` (the booster version that read), `status` (`ok`, `timeout`, `error` or `skipped`, when none of the request's images is one it reads), `latency_ms`, `billed_calls` (the calls of this booster billed on this request), `results` (one reading per image the booster was to read; a picture it did not read, because it timed out or failed, or could not decode, `readable` false, is an `UnknownReading` holding those fields, and `status` says why), `raw` |
388
+ | `AgeReading` | `image`, `faces` (how many faces were found), and `age_years`, `low`, `high`, `confidence`, each `nil` when `faces` is 0 |
389
+ | `AiGeneratedReading` | `image`, `generated` (the probability that a generator made the picture), `confidence`, `method` (`inference`, read from the pixels, or `c2pa`, when the file's trusted Content Credentials declare it generated or edited by AI), `top_generator` (`name` and `probability` of the likeliest generator when the reading says likely generated; `nil` otherwise, and always on a `c2pa` reading), `c2pa_signer` (who signed the file's Content Credentials when they are trusted, else `nil`), `c2pa` (`"untrusted"` when the file carries Content Credentials that validate but are not trusted, which change nothing in the reading; else `nil`) |
390
+ | `FaceDetectionReading` | `image`, `faces` (how many faces the picture shows; 0 is a reading), `detections` (at most 64, each a `FaceDetection`: `box`, a `FaceBox` with the corners `x1`, `y1`, `x2`, `y2` in the request's units, and `confidence`) |
391
+ | `FaceRecognitionReading` | `image`, `faces` (how many faces were searched), `matches` (best first, each a `FaceMatch`: `box`, `score`, `collection`, `reference`, `name` for a public figure, `label` and `metadata` for your own item, `collection_name`), `collection_name` (your collection's name) |
392
+ | `KnowledgeReading` | One reading, not one per picture: the passages found, with their citations (see Knowledge) |
393
+ | `OcrReading` | `image`, `text` (the reading; empty when the picture shows no text), `truncated` (`true` when the reading was cut at the model's context limit) |
394
+ | `DepthReading` | `image`, `ahead` (meters to the nearest obstacle straight ahead, the floor left out; `nil` when none is found), `directions` (7 entries, left to right, each meters or `nil`), `ground` (whether a floor or ground was found and left out), `grid_rows`, `grid_cols`, `grid` (rows of meters from the top left), `depth_min`, `depth_max`, `depth_median`, `width`, `height` (the picture's size in pixels) |
395
+ | `SegmentReading` | `image`, `width`, `height`, `prompts` (the phrases searched for), `instances` (best first, each a `SegmentInstance`: `label`, `score`, `box` `[x1, y1, x2, y2]`, `polygon` `[[x, y], ...]`, `area`, `centroid` `[x, y]`, `opening` (a `SegmentOpening` for the hole of a gate, a doorway or a ring, or `nil`), `depth` (a `SegmentDepth`: `median`, `opening_median`, `opening_rim_median`) and `size_m` (a `SegmentSize` in meters: `width`, `height`, `opening`), both `nil` with depth off), `depth_source`, `assumed_hfov_deg`. Coordinates are fractions of the picture from its top left |
396
+ | `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 still parses. |
397
+
398
+ A request whose boosters name `ocr` waits 30 seconds per attempt when the client has no `timeout:` of its own; a `timeout:` you set is used as given, even below 30. Such a request is never retried after a timeout, because the server finishes that attempt and may bill it: the `TimeoutError` is raised instead. Its other retries stay. Set a timeout of at least 30 seconds for requests that name `ocr`.
399
+
400
+ Booster calls are drawn from the prepaid balance with the request's tokens: one call per booster and image read with status `ok`. A booster that timed out or failed is free, and a refused request bills none. The readings' tokens are input tokens at the model's price. The price per call is on the [pricing page](https://decisionnode.com/docs/pricing#boosters).
401
+
402
+ | Status | `error_type` | Error | When |
403
+ |---|---|---|---|
404
+ | 400 | `api_usage_error` | `BadRequestError` | `boosters` malformed: more than 4 entries, a booster named twice, an entry that is neither a name nor a Hash with `name`, a key in `options` the booster does not take. The reason is in `reason`. |
405
+ | 400 | `booster_unknown` | `BadRequestError` | A name that is not a booster |
406
+ | 400 | `booster_input_mismatch` | `BadRequestError` | A booster that reads images on a request without any, or an `images` entry that is not an image of the request |
407
+ | 403 | `booster_not_enabled` | `PermissionDeniedError` | The booster is turned off for the workspace (an owner or admin turns boosters on and off in the console) |
408
+ | 529 | `booster_unavailable` | `OverloadedError` | A booster with `require: true` failed or did not answer in time; retried |
409
+
410
+ Every error carries `boosters`, the `x-decisionnode-boosters` header as booster name to `DecisionNode::BoosterStatus` (`status`, `latency_ms`) in the request's order, so you can see which required booster failed:
411
+
412
+ ```ruby
413
+ begin
414
+ dn.decide(images: images, questions: questions, boosters: [DecisionNode::Booster.new("ai_generated", require: true)])
415
+ rescue DecisionNode::OverloadedError => e
416
+ e.boosters["ai_generated"]&.status # => "timeout"
417
+ end
418
+ ```
419
+
420
+ ## Fine-tuned models
421
+
422
+ A model your workspace fine-tuned in the console is called by its name in `model:`, by the keys of that workspace, and answers like its base model: the same question types, answers, limits and errors.
423
+
424
+ ```ruby
425
+ d = dn.decide(state: claim, questions: questions, model: "decisionnode-moe@acme/fraud")
426
+ d.model # => "decisionnode-moe@acme/fraud:3", the version that answered
427
+ ```
428
+
429
+ The name is `<base>@<workspace>/<name>`, the base a family id (`decisionnode-moe` or `decisionnode-flash`), which means the version deployed most recently, or `<base>@<workspace>/<name>:<n>` for one version. The SDK sends it as given; a name that does not exist, is not deployed or belongs to another workspace raises the API's `BadRequestError` ("Unknown model: ..."). `Decision#model` is always the pinned name with its version. `models` lists your workspace's fine-tuned models beside the catalogue, each `ModelInfo` with `fine_tuned` (`true`), `base` (the base model), `adapter_version` (its version number) and `status` (`deployed`, which answers decisions, or `ready`, trained but not deployed); these are `nil` on a catalogue model.
430
+
431
+ ## Fallback
432
+
433
+ A `decisionnode-moe` request that no ready worker can take at once is answered by Flash instead, billed at Flash's price. `d.model` is then the model that answered, and `d.fallback` names both: the model that answered by its pinned id, and the model asked for as it was asked (a family id or an older id as the family id, a pinned id as itself):
434
+
435
+ ```ruby
436
+ d = dn.decide(state: ticket, questions: questions, model: "decisionnode-moe")
437
+ d.fallback # => #<struct DecisionNode::Fallback served_model="decisionnode-flash-<version>", requested_model="decisionnode-moe">
438
+ ```
439
+
440
+ `d.fallback` is `nil` on an ordinary answer. Pass `fallback: false` (on `decide`, the helpers, `decide_many` requests and batch requests) to get the 529 `OverloadedError` instead, which is retried like every 529; left out, the API's default (`true`) applies and the request carries no `fallback` field. Sessions, batch lines and fine-tuned models never fall back.
441
+
442
+ ## Checked before sending
443
+
444
+ The SDK checks every fixed rule the API documents before any network call, so the server never sees a request the client could have refused. A fault raises what the server would answer: the same class, the same `error_type`, the documented message, and for a 422 a `ValidationError` whose `entries` are the server's list. A local error has no `status` and no `request_id`. The checks run in the server's order: the nesting limit, the 422 entries, the first 400 in question order, then the images, then the videos.
445
+
446
+ | Fault | Error | `error_type` |
447
+ |---|---|---|
448
+ | More than 128 objects and arrays inside each other; an unknown question type | `BadRequestError`, "Invalid request." | `api_usage_error` |
449
+ | A question that is not a Hash or has no `type`; instructions, a state, an option description, a score level or a truth side that is not a String, Hash or Array; a choice or score without criteria, or with criteria of the wrong shape; a score with no levels | `ValidationError` with the server's `entries` | null |
450
+ | A number grid: a bound that is not a finite number, beyond 2^53, `step` not above 0, more than 6 decimals in `min` or `step`, beyond the decimal bound, `min` above `max`, more than 4,096 values (counted in exact decimals) | `ValidationError` with the server's `entries` | null |
451
+ | A choice with no options or more than 255, a score with more than 10 levels, a truth question with neither instructions nor criteria, a number, point or box question without instructions | `BadRequestError` with the API's documented message | null |
452
+ | A point or box question in a request with no image, without `image:` when the request has two or more, with an `image:` that is not one of the request's image ids, or with `units:` other than `"pixels"` and `"normalized"` | `ValidationError` with the server's `entries` | null |
453
+ | More than 16 images or more than 4 videos (a `too_long` entry on the list, after the state's and the questions' entries) | `ValidationError` with the server's `entries` | null |
454
+ | An empty or repeated image id, an id longer than 64 characters (counted in characters, not bytes: "Image 1: its id is longer than 64 characters.", the image counted from 0), bytes that are not JPEG, PNG or WebP or not of the declared type, over 10,485,760 bytes or 40,000,000 pixels, an upload reference with another media type, a URL that is not `https` | `BadRequestError` | `api_usage_error` |
455
+ | An `https` image URL over 2,048 characters, with user information, on a port other than 443, without a host, or naming a private or reserved address literally | `BadRequestError` | `image_url_refused` |
456
+ | A video fault: see "Videos" | `BadRequestError` with the API's message | `api_usage_error` |
457
+ | `open_session`: `window` outside 1 to 64, `ttl_seconds` outside 1 to 3,600 | `ValidationError` with the server's `entries` | null |
458
+ | `Session#send`: see "Sessions" | `BadRequestError` | `api_usage_error` |
459
+ | `batches.create` and `add`: a missing, empty, non-String or repeated `custom_id`, an add with no requests; `batches.list`: `offset` below 0, `limit` outside 1 to 100 | `BadRequestError` with the API's documented message | `api_usage_error` |
460
+
461
+ After a `models` call the client also holds the limits `/v1/models` publishes and checks them: a session's `max_window` and `max_ttl_seconds`, a model that serves no sessions or answers no number questions, a batch's `requests_per_batch` and `max_custom_id_chars`, and the image and option bounds. A request's model is found in that list by its name, else by the entry that states it as its `alias` or pinned `version`, so `decisionnode-moe` takes the limits of the pinned version it points to. Without that call, or for a model the list does not state, those checks are left to the server. Limits counted in tokens are always the server's to judge. `decide_raw` sends its body unchecked.
462
+
463
+ A bad argument raises Ruby's own `ArgumentError`, never a DecisionNode class: a `base_url`, `timeout`, `max_retries` or `inline_threshold` the client refuses, a `concurrency` below 1, an empty question map, an image file that cannot be read, and a value that cannot be serialised as JSON. A missing API key raises `AuthenticationError`, also without a request.
464
+
465
+ ### Checking without sending
466
+
467
+ `check_request(method, request)` runs every check a call would run before sending, alone: it raises exactly what the call would raise (held `/v1/models` limits included) and returns `nil` when the request would be sent. It sends nothing and uploads nothing. `method` is `"decide"`, `"open_session"`, `"batches.create"` or `"batches.add"`; `request` holds the call's arguments, as a Hash or as keywords.
468
+
469
+ ```ruby
470
+ dn.check_request("decide", state: ticket, questions: questions, images: images, videos: videos) # => nil
471
+ dn.check_request("open_session", questions: questions, window: 128)
472
+ # raises DecisionNode::ValidationError, as open_session would
473
+ dn.check_request("batches.create", requests: requests)
474
+ ```
475
+
476
+ `Session#check_frame(frame:, image:, questions:, reset:, seq:)` does the same for a frame: it raises what `Session#send` would and writes nothing, uses no `seq`, and needs no connected socket.
477
+
478
+ ## Collections
479
+
480
+ A collection holds faces your workspace enrolled, each under your own `reference`, so a `face_recognition` booster can match a picture against them. `dn.collections` returns each answer as the API sent it, a Hash with the API's field names.
481
+
482
+ ```ruby
483
+ staff = dn.collections.create(name: "Staff")
484
+ dn.collections.enroll(staff["id"], image: DecisionNode::Image.new("badge", path: "badge.jpg"),
485
+ reference: "emp-1042", label: "Reception", metadata: { "desk" => 4, "remote" => false })
486
+
487
+ page = dn.collections.list_items(staff["id"], reference_prefix: "emp-")
488
+ item = page["items"].first
489
+ dn.collections.update_item(staff["id"], item["id"], label: nil) # clears the label
490
+ dn.collections.update_item(staff["id"], item["id"], metadata: { "desk" => 7 }) # replaces the whole object
491
+ ```
492
+
493
+ | Call | Sends |
494
+ |---|---|
495
+ | `create(name:, type: "face")` | `POST /v1/collections` |
496
+ | `list`, `get(id)`, `delete(id)` | `GET /v1/collections`, `GET` and `DELETE /v1/collections/{id}` |
497
+ | `enroll(id, image:, reference:, label: nil, metadata: nil)` | one face from one image, sent as a decision sends its images |
498
+ | `update_item(id, item_id, reference:, label:, metadata:)` | `PATCH` with only the fields given: `label: nil` clears the label, `metadata` replaces the item's whole object (`{}` clears it) |
499
+ | `list_items(id, offset:, limit:, reference:, reference_prefix:)` | one page; `reference` finds the items of one reference, `reference_prefix` those whose reference starts with it (not both) |
500
+ | `delete_item(id, item_id)` | removes one item |
501
+
502
+ A reference is 1 to 64 of `A-Z a-z 0-9 _ . : -`, a label 1 to 128 characters, and metadata a Hash of at most 20 keys of 1 to 40 of `a-z 0-9 _`, each value a text of at most 500 characters, a number, true or false. They are checked before sending, with the API's `ValidationError` or `BadRequestError`. `create`, `enroll` and `update_item` send an `Idempotency-Key`, a new UUID for each call and the same on every attempt of it, so the API acts and bills once however often the call is tried. They retry like every call, and also on a 409 `idempotency_in_progress` after its `Retry-After`; a 409 `idempotency_key_reused` is raised.
503
+
504
+ ## Knowledge
505
+
506
+ A knowledge base holds your workspace's own documents: a claims policy, house rules, a mission's special instructions. The `knowledge` booster finds the passages that apply to a request and gives them to the decision as facts and rules to apply, with citations back to each document. A knowledge base is a collection of type `text`, under `dn.collections` with the face collections, and every call returns the API's answer as sent, a Hash with the API's field names.
507
+
508
+ ### 1. Create a knowledge base
509
+
510
+ ```ruby
511
+ kb = dn.collections.create(name: "Claims policy", type: "text", scope_key: "mission")
512
+ kb["id"] # => "col_3f6c..."
513
+ ```
514
+
515
+ `scope_key` is optional, 1 to 40 of `a-z 0-9 _`, and fixed for the knowledge base's life. It names the one metadata key whose value scopes a document to, say, one mission (step 3).
516
+
517
+ ### 2. Add documents
518
+
519
+ ```ruby
520
+ policy = DecisionNode::Document.new(text: <<~MD)
521
+ # Receipts
522
+
523
+ Reject any claim over 500 SEK without a receipt.
524
+ MD
525
+ dn.collections.add_document(kb["id"], document: policy, title: "Claims policy 2026", reference: "policy-2026")
526
+
527
+ item = dn.collections.add_document(kb["id"], document: DecisionNode::Document.new(path: "handbook.pdf"))
528
+ item["status"] # => "queued"
529
+ ```
530
+
531
+ | `DecisionNode::Document` | What it is |
532
+ |---|---|
533
+ | `Document.new(text:, format: nil)` | Text in memory, `format` `"markdown"` (the API's default) or `"text"`. A text needs a `title:` |
534
+ | `Document.new(path:, content_type: nil, filename: nil)` | A local file, read when the call is made |
535
+ | `Document.new(data:, content_type: nil, filename: nil)` | The file's bytes |
536
+ | `Document.new(io:, content_type: nil, filename: nil)` | An IO, read to its end once, into memory, before anything is sent |
537
+ | `Document.new(upload_id:)` | A file uploaded before with `dn.upload_documents` |
538
+
539
+ A file's type comes from `content_type`, else from its name's extension, else from its first bytes (PDF, PNG, JPEG, TIFF, WebP); DOCX, PPTX, XLSX and text need a `content_type` or a file name. A file's title is its name without the extension unless you give `title:`. A text, and a text or Markdown file under the client's `inline_threshold` (512 KiB) that is UTF-8, go inline in one call; every other file is uploaded first (one presign and one PUT, 120 seconds per attempt or the client's `upload_timeout:`) and named by its upload id. `dn.upload_documents([doc, ...])` uploads 1 to 16 documents ahead of time and returns their upload ids, each usable for an hour.
540
+
541
+ PDF, DOCX, PPTX, XLSX, HTML and pictures are converted to text. Only the text is kept: the original file is deleted once its text is ready.
542
+
543
+ Ingestion is asynchronous: a document is `queued`, then `processing` (`stage` `extracting` for a converted file, `chunking`, `embedding`), then `ready` or `failed`, whose `error` says why. Wait for it in the call, or after:
544
+
545
+ ```ruby
546
+ dn.collections.add_document(kb["id"], document: policy, title: "Claims policy 2026", wait: true) # up to 10 s on the server
547
+ dn.collections.wait_until_ready(kb["id"], item["id"]) # the item, ready or failed
548
+ dn.collections.wait_until_ready(kb["id"], timeout: 600) # the knowledge base, once nothing is queued or processing
549
+ ```
550
+
551
+ `wait: true` holds the call on the server up to 10 seconds and answers once the document is ready, else as it stands; such a call waits 20 seconds per attempt unless you set the client's `timeout:`. `wait_until_ready` reads at once, then every `poll_interval:` seconds (default 1, half as long again after each read, at most 10), and raises `DecisionNode::TimeoutError` past `timeout:` seconds (default 300).
552
+
553
+ ### 3. Pin rules and scope documents
554
+
555
+ A rule that must always apply is pinned: a pinned document is given to every request that names the knowledge base, before any passage found by search. A document whose metadata holds the scope key's value is seen only by requests that name that value; a document without it is global.
556
+
557
+ ```ruby
558
+ dn.collections.add_document(kb["id"], document: DecisionNode::Document.new(text: "Never approve a claim over 10,000 SEK."),
559
+ title: "Hard limit", pinned: true, pin_rank: 0)
560
+ dn.collections.add_document(kb["id"], document: DecisionNode::Document.new(path: "m42-brief.md"),
561
+ metadata: { "mission" => "M-42" })
562
+ dn.collections.update_item(kb["id"], item["id"], pinned: true) # applied at once, never billed again
563
+ ```
564
+
565
+ ### 4. Decide with knowledge
566
+
567
+ ```ruby
568
+ d = dn.decide(
569
+ state: { claim: { amount: "740.00", receipt: false }, mission: "M-42" },
570
+ questions: { approve: DecisionNode.truth("Should the claim be approved under our policy?") },
571
+ boosters: [DecisionNode.knowledge(kb["id"], scope: "M-42", require: true)]
572
+ )
573
+
574
+ reading = d.evidence["knowledge"].results.first # a DecisionNode::KnowledgeReading
575
+ reading.passages.each do |p|
576
+ puts [p.n, p.pinned, p.title, p.heading, p.score].inspect
577
+ end
578
+
579
+ p = reading.passages.first
580
+ text = dn.collections.get_item(kb["id"], p.item, include_text: true)["text"]
581
+ text.byteslice(p.byte_start, p.byte_end - p.byte_start) # the passage, cited
582
+ ```
583
+
584
+ `DecisionNode.knowledge(collection, query:, scope:, match:, filter:, top_k:, max_tokens:, pinned:, min_score:, include_text:, require:)` builds the booster; each option is sent only when given, as given, and the API checks them. `collection` is required; `scope` names the scope key's value; `match` and `filter` select documents by metadata; `top_k` is the most passages found by search (4 by default; 0 gives the pins only); `max_tokens` is the passages' budget (`"auto"` or 128 to 8,192); `pinned: false` leaves the pins out; `min_score` is a floor for passages found by meaning; `include_text: true` adds each passage's text (a full-scope key). `require: true` fails the request when the reading did not run. The booster reads no picture and goes with `decide`, the helpers, `decide_many` and batch requests.
585
+
586
+ | Class | Fields |
587
+ |---|---|
588
+ | `KnowledgeReading` | `collection`, `collection_name`, `corpus` (`{"version"}`), `query` (`{"source", "tokens", "text"}`), `budget` (`{"mode", "tokens", "entry", "cause"}`), `scope` (`{"key", "value"}`), `warnings` (`scope_not_named`, `over_budget`, `budget_floor`, `pinned_filtered`), `passages`, `omitted` (counts by cause), `pending_items`, `search`, `truncated` |
589
+ | `KnowledgePassage` | `n` (its number in the block), `pinned`, `item`, `chunk`, `byte_start`, `byte_end`, `reference`, `title`, `heading`, `tokens`, `score` (`nil` for a pin), `lexical`, `rendered`, `text` |
590
+
591
+ `byte_start` and `byte_end` are UTF-8 byte offsets into the document's stored text, end exclusive: slice with `byteslice`, as above. A publishable key's reading has `collection_name`, `reference`, `title` and `heading` `nil`. A reading with a field missing or of the wrong type is an `UnknownReading` holding its raw fields, and the decision still parses.
592
+
593
+ ### 5. Search without a decision
594
+
595
+ ```ruby
596
+ found = dn.collections.search(kb["id"], query: "claims without a receipt", scope: "M-42", top_k: 3)
597
+ found.passages.map(&:text)
598
+ ```
599
+
600
+ `search` takes the booster's options and returns a `KnowledgeReading` with every passage's text. It is billed as one knowledge call and needs a full-scope key.
601
+
602
+ ### 6. Errors
603
+
604
+ | Status | `error_type` | Error | When |
605
+ |---|---|---|---|
606
+ | 409 | `knowledge_collection_empty` | `DecisionNodeError` | `require: true` on a knowledge base with nothing ready and nothing pending: `details["items"]`, `details["failed"]` |
607
+ | 529 | `booster_unavailable` | `OverloadedError` | `require: true` while no document is ready yet but some are being ingested: retried after its `Retry-After` (5 s). With `details["reason"]` `ingestion_delayed` (`Retry-After` 60 s) it is raised at once, never retried: ingestion is delayed and you decide when to try again |
608
+ | 429 | `rate_limit_error` | `RateLimitError` | The workspace's ingestion rate: `details["reason"]` `ingest_queue_full` or `ingest_tokens_per_hour`; retried after its `Retry-After` |
609
+ | 413 | `item_too_large` | `RequestTooLargeError` | A text over 512 KiB once normalised |
610
+ | 422 | `invalid_text` | `ValidationError` | A text that is not UTF-8 or holds a NUL; `entries` is empty |
611
+ | 409 | `reference_taken`, `pinned_budget_exceeded`, `chunks_limit_reached` | `DecisionNodeError` | The reference is another document's (`details["item_id"]`), the pinned documents are over their budget, the workspace's chunks are used up |
612
+ | 400 | `collection_type_mismatch` | `BadRequestError` | A face collection named by `knowledge` or `search` |
613
+ | 400 | `upload_purpose_mismatch` | `BadRequestError` | An image upload named as a document, or a document upload named as an image |
614
+ | 403 | `key_scope_denied` | `PermissionDeniedError` | `search`, `include_text` or a document upload from a key that is not full-scope |
615
+
616
+ ### 7. Files and limits
617
+
618
+ | What | Limit |
619
+ |---|---|
620
+ | File types | Text, Markdown, PDF, DOCX, PPTX, XLSX, HTML, PNG, JPEG, TIFF, WebP |
621
+ | A file | 50 MB; a PDF 100 pages |
622
+ | A document's text | 512 KiB once extracted and normalised |
623
+ | `title` | 1 to 200 characters |
624
+ | `pin_rank` | 0 to 1,000 |
625
+
626
+ The title, `pin_rank`, a text's format, an empty text, the reference, the label, the metadata, the scope key, a file's size and type, and the paging of `list_items` and `list_chunks` are checked before sending, with the API's `ValidationError` or `BadRequestError`.
627
+
628
+ | Call | Sends |
629
+ |---|---|
630
+ | `create(name:, type: "text", scope_key: nil)` | `POST /v1/collections` |
631
+ | `add_document(id, document:, title:, reference:, pinned:, pin_rank:, label:, metadata:, wait: false)` | `POST /v1/collections/{id}/items` |
632
+ | `replace_document(id, item_id, document:, ...)` | `PUT`: a new version, the old one serving until it is ready; the same text again changes nothing |
633
+ | `update_item(id, item_id, reference:, label:, metadata:, pinned:, pin_rank:, title:)` | `PATCH` with only the fields given; a new title makes a new version |
634
+ | `get_item(id, item_id, include_text: false)` | `GET`, with the stored text when asked |
635
+ | `list_items(id, offset:, limit:, reference:, reference_prefix:, q:, sort:)`, `list_chunks(id, item_id, offset:, limit:)` | One page of documents (never their text; `q`, 1 to 200 characters, finds them by title, file name or reference, `sort` is `"newest"`, `"oldest"` or `"title"`), one page of a document's chunks |
636
+ | `delete_item(id, item_id)`, `delete_failed(id)`, `delete(id)` | One document; every failed document (`{"deleted" => n}`); the knowledge base (`{"deleted" => true}`, or `{"status" => "purging"}` while it finishes) |
637
+ | `wait_until_ready(id, item_id = nil, timeout: 300, poll_interval: 1)` | Reads until ready |
638
+ | `search(id, query:, ...)` | `POST /v1/collections/{id}/search` |
639
+
640
+ `add_document`, `replace_document` and `update_item` send an `Idempotency-Key`, the same on every attempt of a call. A knowledge call costs USD 0.001, one a request whatever it retrieves, with ingestion and storage on the [pricing page](https://decisionnode.com/docs/pricing#boosters).
641
+
642
+ ## Device keys
643
+
644
+ A Manage device keys key, kept on your provisioning server, makes a Decisions-only key for each device as it enrolls, and changes or revokes it later. `dn.keys` returns each answer as the API sent it, a Hash with the API's field names.
645
+
646
+ ```ruby
647
+ admin = DecisionNode.new(api_key: ENV.fetch("DECISIONNODE_MANAGE_KEY"))
648
+
649
+ created = admin.keys.create("Kiosk 0412", device_id: "kiosk-0412", labels: { "store" => "118", "fw" => "2.4" },
650
+ limits: { "requests_per_second" => 5, "tokens_per_second" => 20_000 },
651
+ daily_spend_cap: { "usd" => 2.0 },
652
+ idempotency_key: "enroll-kiosk-0412") # a retried enrollment gets the same key back
653
+ secret = created["secret"] # hand it to the device once; never log it
654
+
655
+ admin.keys.update(created["key"]["id"], labels: { "store" => "120" })
656
+ loop { break unless admin.keys.revoke_by_labels({ "store" => "118" })["more"] }
657
+ ```
658
+
659
+ | Call | Sends |
660
+ |---|---|
661
+ | `create(name, device_id:, labels:, limits:, daily_spend_cap:, expires_at:, may_search_collections:, idempotency_key:)` | `POST /v1/keys`, each field only when given: `{key, secret}`, the secret shown only here |
662
+ | `list(labels:, device_id:, status:, limit:, cursor:)` | one page of keys, newest first: `{items, next_cursor}`; `labels` keeps the keys with all of them, `status` is `active` (the default), `expired`, `revoked` or `all`, `limit` 1 to 200 |
663
+ | `all(labels:, device_id:, status:, limit:)` | every key `list` finds, its pages followed to the end |
664
+ | `update(key_id, name:, labels:, limits:, daily_spend_cap:, expires_at:, may_search_collections:)` | `PATCH` with only the fields given: `labels` replaces the whole set, and `nil` clears `labels`, `limits`, `daily_spend_cap` or `expires_at`; a key's `device_id` never changes |
665
+ | `revoke(key_id)` | `DELETE /v1/keys/{id}`: revoked at once |
666
+ | `revoke_by_labels(labels, device_ids:, dry_run:)` | every active key with all of `labels`, at most 10,000 a call: `{revoked, more}`; call again while `more` is true. `dry_run: true` counts them instead: `{would_revoke, more}` |
667
+
668
+ `create` sends an `Idempotency-Key`: yours when you pass one (one per enrollment), else a new UUID for each call, the same on every attempt of it, so a retried call makes one key. A retry with the same key and body within 24 hours returns the same key and secret.
669
+
670
+ What the API checks by fixed rules is checked before anything is sent, with its messages: a key's name (1 to 48 characters once trimmed), a `device_id` (1 to 128 printable ASCII characters, not starting with a space), labels (at most 16, each name 1 to 32 of `a-z`, `0-9`, `_`, `.` and `-`, each value 1 to 64 characters), `limits` being a Hash, and a `daily_spend_cap` from USD 0.01 to 1,000 in whole cents raise the API's `ValidationError` (the first fault, in the body's order); an update with nothing to change, a malformed `idempotency_key`, `list`'s filters and `limit`, and a bulk revoke without a label or with over 1,000 `device_ids` raise its `BadRequestError` or `ValidationError`. A limit's ceiling (your workspace's own), the expiry's window and a name's characters are the API's to judge. The whole flow is in [Keys on devices](https://decisionnode.com/docs/patterns/device-keys).
671
+
672
+ ## Per-key usage
673
+
674
+ What each key used and cost over a period: a Full access key reads every key of its workspace, a Manage device keys key its Decisions-only keys. `dn.usage` returns each answer as the API sent it.
675
+
676
+ ```ruby
677
+ one = dn.usage.key("key_7c2e9a41b0d34f6e8a5c1d2b3e4f5a60", granularity: "day")
678
+ items = dn.usage.all(start: "2026-10-01T00:00:00Z", end: "2026-11-01T00:00:00Z",
679
+ labels: { "customer" => "acme" }, status: "all")
680
+ items.each { |item| puts "#{item['key']['name']}: #{item['totals']['cost']['usd']}" }
681
+ ```
682
+
683
+ | Call | Sends |
684
+ |---|---|
685
+ | `key(key_id, start:, end:, granularity:, group_by:)` | `GET /v1/keys/{id}/usage`; without `start` and `end`, the UTC month so far |
686
+ | `list(start:, end:, granularity:, group_by:, labels:, device_id:, key_ids:, scope:, status:, include_empty:, limit:, cursor:)` | `GET /v1/usage`, one page: `{period, as_of, items, next_cursor}`; pass `next_cursor` as `cursor` until it is `nil` |
687
+ | `all(start:, end:, ...)` | `list`'s arguments but `cursor`: every page's `items`, in order |
688
+
689
+ `start` and `end` are RFC 3339 times, rounded to the hour. `granularity` (`none`, `hour`, `day` or `month`) adds `buckets`, and `group_by` (`model` or `channel`) `by_model` or `by_channel`. `labels` keeps the keys with all of them, `key_ids` (at most 100) the keys named, `scope` (`full`, `decide` or `keys`) and `status` (`active`, `expired`, `revoked` or `all`) the keys of that kind or state; `include_empty: true` lists keys with no usage too; `limit` is 1 to 1,000. A page can hold fewer keys than `limit`, so only `next_cursor` says when the list is done: `all` follows it for you. These query rules are checked before sending, with the API's messages; the period's own rules are the server's. See [Usage](https://decisionnode.com/docs/api/usage).
690
+
691
+ ## Sessions
692
+
693
+ A session holds one state and a fixed set of questions, and answers them again for every frame you send on its socket: telemetry, a camera feed, a conversation. Frames are answered as they arrive and the newest wins: a frame sent while another waits replaces it, and the reply says which frames it replaced in `dropped`.
694
+
695
+ ```ruby
696
+ s = dn.open_session(
697
+ state: { mission: "Survey the north field" },
698
+ questions: {
699
+ mode: DecisionNode.choice("Which flight mode now?", %w[continue hold return land]),
700
+ abort: DecisionNode.truth("Should the mission be aborted now?")
701
+ }
702
+ )
703
+ s.connect
704
+ s.send(frame: { battery: 0.31, wind_mps: 11.4 }) # => 1, the frame's seq
705
+ s.send(frame: "approaching the tree line", image: DecisionNode::Image.new("frame", data: jpeg))
706
+
707
+ s.replies.each do |event|
708
+ case event
709
+ when DecisionNode::Reply then puts event["mode"].choice
710
+ when DecisionNode::FrameError then warn "#{event.seq}: #{event.error_type} #{event.message}"
711
+ when DecisionNode::SessionClosed then warn "closed #{event.code}"
712
+ end
713
+ end
714
+ s.end
715
+ ```
716
+
717
+ | Method | Does |
718
+ |---|---|
719
+ | `Session#connect` | Opens the socket, with the API key in the `Authorization` header (never in the URL) and the client's custom headers on the handshake |
720
+ | `Session#send(frame:, image:, questions:, reset:, seq:)` | Sends one frame and returns its `seq`, counted from 1 unless given; never waits for a reply. A frame's image follows the threshold of "Images". A frame that breaks a fixed rule (no frame and no image, an empty frame, a `seq` out of range, an empty question list or an id the session does not have, a `reset` that is not a boolean, nesting over 128, a URL image, an image the API would refuse) raises `BadRequestError`: nothing is written and the frame takes no `seq` |
721
+ | `Session#replies` | Each event in turn: `Reply` and `FrameError` values (a frame error is a value, never raised), then the `SessionClosed` that ends the stream. `next_event(timeout:)` reads one |
722
+ | `Session#end` | Ends the session; a session already gone counts as ended |
723
+ | `Session#close` | Closes the socket |
724
+
725
+ `Reply` has `seq`, `answers` (typed as in a `Decision`), `dropped`, `usage`, `latency_ms` and `safety`. `FrameError` has `seq`, `error_type`, `message`, `category`, `retry_after_ms`, `usage` and `safety`. `SessionClosed` has `code`, `reason` and `reopen`: true for the codes after which a new session with the same body is the remedy (1001, 1006, 1011, 4404, 4408, 4409, 4410), false otherwise. Pings are answered at once, and a socket that hears nothing for 65 seconds is closed as 1006.
726
+
727
+ `supervise` runs one long-lived feed through sessions and reopens them as the close codes say:
728
+
729
+ ```ruby
730
+ readings = Enumerator.new { |y| loop { y << { frame: telemetry.read } } }
731
+ closed = dn.supervise({ state: { mission: "Survey" }, questions: questions }, readings) do |event|
732
+ handle(event) # every Reply and FrameError
733
+ end
734
+ ```
735
+
736
+ It reopens with the same body after 1001, 1006, 1011, 4404, 4409 (after a capped backoff with jitter, 1 to 30 seconds), and after 4408 and 4410 (without a wait), connects again to the same session after 1013, and stops on any other code. When the feed ends it waits for the outstanding replies, ends the session and returns the last `SessionClosed` (nil when none was seen); when it stops on a code it returns that `SessionClosed`.
737
+
738
+ Opening a session when the key already holds its most open sessions raises `DecisionNode::SessionLimitError`, a `RateLimitError`, and is never retried.
739
+
740
+ ## Batches
741
+
742
+ The Batch API answers requests at the batch price: 50% off the live price on `decisionnode-flash`, 30% off on `decisionnode-moe`. The batch price is each model's, its `batch_price_factor` in `models` (0.5 and 0.7; a fine-tuned model's entry states none), never the batch's, and each line's charge is in its usage. There is no set time at which a batch runs or finishes, so poll for it. Requests not answered within 24 hours of finalizing expire and are not charged. A request is a Hash of `decide`'s arguments plus a `custom_id`.
743
+
744
+ ```ruby
745
+ requests = tickets.map.with_index do |t, i|
746
+ { custom_id: "ticket-#{i}", state: t, questions: { route: route_question } }
747
+ end
748
+
749
+ batch, results = dn.run_batch(requests, poll_interval: 60)
750
+ results.each do |r|
751
+ if r.decision then puts "#{r.custom_id}: #{r.decision['route'].choice}"
752
+ else warn "#{r.custom_id}: #{r.status} #{r.error&.message}"
753
+ end
754
+ end
755
+ ```
756
+
757
+ `run_batch` creates an empty draft, adds the requests (in calls of at most 10 MiB), finalizes it, reads the batch every `poll_interval` seconds (at least 10; the server's `Retry-After` when it is longer) until it ends, and returns the ended `Batch` and its results. If adding fails, the draft is cancelled and the error raised. Past `timeout` seconds (26 hours by default) it raises `TimeoutError` and the batch keeps running.
758
+
759
+ The calls one by one, on `dn.batches`:
760
+
761
+ | Method | Sends | Returns |
762
+ |---|---|---|
763
+ | `create(requests = [])` | `POST /v1/batches`, JSON Lines; with no requests an empty draft | `Batch` |
764
+ | `add(batch_id, requests)` | `POST /v1/batches/{id}/requests`, split into calls of at most 10 MiB, in order | `Batch` |
765
+ | `finalize(batch_id)` | `POST /v1/batches/{id}/finalize` | `Batch` |
766
+ | `get(batch_id)` | `GET /v1/batches/{id}` | `Batch` |
767
+ | `list(offset: nil, limit: nil)` | `GET /v1/batches` | `BatchPage` (`batches`, `offset`, `limit`, `total`, `next_offset`) |
768
+ | `results(batch_id)` | `GET /v1/batches/{id}/results`, read from the network line by line as it arrives | One `BatchResult` per request, in upload order; with a block, each one as its line arrives |
769
+ | `cancel(batch_id)` | `POST /v1/batches/{id}/cancel` | `Batch` |
770
+
771
+ A `Batch` has every field of the batch object by name (`id`, `state`, `counters`, `created_at`, `finalized_at`, `expires_at`, `ended_at`, `results_url`, `results_expire_at`, `usage`, `error`) and `ended?`. A `BatchResult` has `custom_id`, `status` (an Integer, or `"expired"` or `"cancelled"`), `headers`, `decision` (a `Decision` for a 200 line) and `error` (for an error line, the error `decide` would have raised, as a value).
772
+
773
+ Every line carries `custom_id`, `model` (the request's, else the client's) and `state` (`""` when absent), and every line of a call is checked before anything is sent. Its images follow the threshold of "Images", per line, and its videos go inline with it. `get`, `list`, `results` and `cancel` are retried like every call. `create`, `add` and `finalize` are retried only on 429 and 529, never after a timeout, a lost answer or a 5xx, since a repeat could make a second draft. A `finalize` answered with a 409 is resolved by reading the batch: if it is no longer a draft, the earlier finalize went through and the batch is returned.
774
+
775
+ ## Safety
776
+
777
+ Every decision from the hosted API carries the result of its safety check, parsed into `decision.safety`:
778
+
779
+ ```ruby
780
+ s = d.safety
781
+ s.checked? # => true
782
+ s.harm # => 0.031
783
+ s.self_harm # => 0.004
784
+ s.flagged # => [] (the categories above your key's thresholds)
785
+ s.reason # why the check did not run, when checked? is false
786
+ ```
787
+
788
+ `safety` is nil when the server sends no safety result, as a self-hosted server does. A request that is refused raises `DecisionNode::RefusalError` and is never retried.
789
+
790
+ ### The safety result in the response body
791
+
792
+ The safety result always arrives in the `x-decisionnode-safety` response header. To receive it in the response body as well, send the request header `x-decisionnode-safety-body` with the value `true` (`1` and `yes` also work). Pass it through `headers:`, which a client sends with every request:
793
+
794
+ ```ruby
795
+ dn = DecisionNode.new(headers: { "x-decisionnode-safety-body" => "true" })
796
+
797
+ d = dn.decide(state: text, questions: questions)
798
+ d.raw["safety"]
799
+ # => {"checked" => true, "harm" => 0.0312, "self_harm" => 0.0041, "flagged" => []}
800
+ ```
801
+
802
+ The body then carries one more top-level field, `safety`, read from `decision.raw`; it is `{"checked" => false, "reason" => "<code>"}` when the check did not run. Without the header the body has no `safety` field. `decision.safety`, parsed from the response header, is the same with or without it.
803
+
804
+ ## Errors
805
+
806
+ Every error derives from `DecisionNode::DecisionNodeError`, which has `status`, `error_type`, `message`, `request_id`, `reason`, `boosters` (see "Boosters"; an empty Hash for a request without boosters) and `details` (the error body's `detail.details` when it is an object, for example `{"reason" => "enrollment_unpriced"}` on an enrollment's 529, else `nil`). The error's `message` is the server's message, or the SDK's description when no response arrived; it is an empty string when the server sent none. `reason` is the server's explanation in words from the `x-decisionnode-error` header, for example `question 'q': a score takes up to 10 levels, got 11`. The report Ruby prints for an uncaught error adds the class, status and reason.
807
+
808
+ | Status | Error | Extra fields |
809
+ |---|---|---|
810
+ | 400 | `BadRequestError`; `ContextLimitError` when `error_type` is `max_tokens_exceeded` | |
811
+ | 401 | `AuthenticationError` | |
812
+ | 402 | `InsufficientCreditError` | |
813
+ | 403 | `RefusalError` when the request was refused (`error_type` `refusal_error`), else `PermissionDeniedError` (a booster turned off for the workspace, a suspended workspace, a call outside the key's kind (`key_scope_denied`), and for a publishable key `origin_not_allowed` and `model_not_allowed`, its models in `details["allowed"]`; `error_type` tells them apart) | `category` (`harm` or `self_harm`), `safety` |
814
+ | 404 | `NotFoundError` | |
815
+ | 413 | `RequestTooLargeError`: the body is over 26,214,400 bytes (25 MiB); also raised before sending, with no status | |
816
+ | 422 | `ValidationError` | `entries` |
817
+ | 429 | `RateLimitError`; `SessionLimitError` when `error_type` is `session_limit_exceeded`; `error_type` `key_spend_cap_reached` for a key past its daily spend cap, with `details` `resets_at`, `cap` and `spent`; a publishable key's per-address rate and calls in flight with `details["reason"]` `client_rate` or `in_flight` | `retry_after` (seconds) |
818
+ | 529 | `OverloadedError`, also for `booster_unavailable` (a required booster failed) | `retry_after` (seconds) |
819
+ | other 5xx, a 2xx whose body is not JSON, or a helper's missing or malformed answer | `ServerError` | |
820
+ | any other status (a 3xx, 405, 409, 410, 408 once its retries are spent) | `DecisionNodeError` itself, with `status` | |
821
+ | no response | `ConnectionError`, `TimeoutError` | |
822
+
823
+ An error whose body is not the API's JSON (a page from a proxy or the service's edge, or no body at all) is classified by its status alone, from the table above: a 400 is a `BadRequestError` and a 403 a `PermissionDeniedError`, never their subclasses. Its `error_type` is `nil`, `request_id` and `reason` come from the headers, and `message` is the SDK's own description, `HTTP <status> <text>` with the request id after it when there is one: `HTTP 413 Content Too Large`, `HTTP 408 Request Timeout (request id req_edge_0408)`. The body is never read into the error. Retries follow the status as for any error.
824
+
825
+ An image upload that storage refuses raises `DecisionNodeError` itself with the storage's `status`, `error_type` `upload_failed`, and the storage's error code in `reason`.
826
+
827
+ ```ruby
828
+ begin
829
+ dn.decide(state: text, questions: questions)
830
+ rescue DecisionNode::ValidationError => e
831
+ warn e.entries.inspect
832
+ rescue DecisionNode::RateLimitError => e
833
+ sleep(e.retry_after || 1)
834
+ retry
835
+ rescue DecisionNode::DecisionNodeError => e
836
+ warn "#{e.class} #{e.status}: #{e.message} (request #{e.request_id})"
837
+ end
838
+ ```
839
+
840
+ ## Retries and timeouts
841
+
842
+ - The timeout is 10 seconds per attempt, and it covers the whole attempt: from sending the request to the last byte of the body, not each network step. A server that sends its answer slowly, a few bytes at a time, still ends the attempt at the deadline with a `TimeoutError`.
843
+ - Only a 2xx status is a success. Redirects are never followed, so the API key is never sent to another URL or over plain HTTP; a 3xx raises `DecisionNodeError` with its status.
844
+ - A request is retried twice after the first attempt on 408, 429, any 5xx, timeouts and connection errors. The wait is the server's `retry-after-ms`, else `Retry-After` in seconds or as an HTTP date, capped at 60 seconds; without either, an exponential backoff from 0.5 seconds with jitter, capped at 8 seconds. A decision changes nothing on the server, so a retry is always safe.
845
+ - 400, 401, 402, 403, 404, 413 and 422 are never retried, and neither is a session limit, nor a 429 `key_spend_cap_reached` (the key's cap resets at 00:00 UTC, so it is raised at once), nor a 529 whose `details` reason is `enrollment_unpriced` (an enrollment with no price, which a retry never cures). Batch create, add and finalize follow their own rule (see "Batches"). An image `PUT` to storage is retried on 5xx and connection errors in the same way.
846
+
847
+ ## Configuration
848
+
849
+ | Setting | Argument | Environment variable | Default |
850
+ |---|---|---|---|
851
+ | API key | `api_key:` | `DECISIONNODE_API_KEY` | none |
852
+ | Base URL | `base_url:` | `DECISIONNODE_BASE_URL` | `https://api.decisionnode.com` |
853
+ | Model | `model:` | `DECISIONNODE_MODEL` | `decisionnode-moe` |
854
+ | Inline threshold | `inline_threshold:` | | `524288` bytes |
855
+ | Upload timeout | `upload_timeout:` | | `60` seconds per image `PUT` |
856
+
857
+ Model ids: the family ids `decisionnode-moe` (the main model) and `decisionnode-flash` (faster and lower cost) always mean the current release. A pinned id is `decisionnode-<family>-<version>`, with the versions `models` lists, and every answer's `model` is the pinned id that answered. `model:` is free text: a pinned id, a fine-tuned model's name and the older ids the API still accepts (`decisionnode-1.0`, `decisionnode-1.0-flash`, `decisionnode-latest`, `decisionnode-flash-latest`) are sent as given.
858
+
859
+ The SDK sends no telemetry. The only header it adds is `User-Agent: decisionnode-ruby/<version>`. To receive the safety result in the response body as well, send the request header `x-decisionnode-safety-body: true` through `headers:` (see "The safety result in the response body").
860
+
861
+ With a `logger:`, each attempt is logged at debug level with its method, path, status, request id and timing. State, questions, answers and image bytes are never logged.
862
+
863
+ To set a proxy or TLS options, pass the default transport built with them:
864
+
865
+ ```ruby
866
+ transport = DecisionNode::NetHTTPTransport.new(
867
+ proxy: "http://proxy.example.com:3128",
868
+ net_http: { ca_file: "/etc/ssl/certs/company-ca.pem" } # Net::HTTP settings, by name
869
+ )
870
+ dn = DecisionNode.new(http_client: transport)
871
+ ```
872
+
873
+ `net_http:` may add trust (`ca_file`, `ca_path`, `cert_store`) or a client certificate (`cert`, `key`), but it can never turn TLS or its checks off. `use_ssl`, a `verify_mode` other than `OpenSSL::SSL::VERIFY_PEER`, `verify_hostname: false` and `verify_callback` raise `ArgumentError` when the transport is made, and for an `https` URL the transport sets TLS with peer and hostname checks again after applying your settings. `max_idle:` (16) sets how many idle connections are kept per host and `keep_alive_timeout:` (15 seconds) how long an idle one is reused.
874
+
875
+ To use another HTTP stack, pass `http_client:`, an object with `call(method, url, headers, body, timeout)` that returns a `DecisionNode::HTTPResponse` and raises `DecisionNode::TimeoutError` or `DecisionNode::ConnectionError` when no response arrives. It is called with `GET`, `POST`, `DELETE`, and `PUT` for image uploads to storage, which must carry exactly the headers given. The client holds every transport to the same rules as its own:
876
+
877
+ - The timeout covers the whole call: the client ends an attempt at its deadline, whatever the transport is doing.
878
+ - A transport may also answer `stream(method, url, headers, timeout) { |chunk| ... }`, handing a 2xx body over in chunks as it arrives; batch results are then read line by line. Without it they are read from the whole body.
879
+ - Redirects are never followed, so the API key is never sent to another URL. A transport must return a 3xx as it is and say so by answering `false` to `follow_redirects?`; any other transport is refused with `ArgumentError` when the client is made. A transport may also set `url` on the `HTTPResponse` to the address the response came from; a response from any other address than the one requested raises `DecisionNodeError`.
880
+
881
+ ```ruby
882
+ class MyTransport
883
+ def follow_redirects? = false
884
+
885
+ def call(method, url, headers, body, timeout)
886
+ # send the request without following redirects
887
+ DecisionNode::HTTPResponse.new(status, lower_case_headers, body_text)
888
+ end
889
+ end
890
+ ```
891
+
892
+ A session's socket is opened with the standard library's TCP and TLS sockets and the `websocket-driver` gem, and follows the default transport as far as a WebSocket can: it goes through the transport's `proxy:` (else the proxy the environment names, `no_proxy` honoured) with an HTTP `CONNECT` tunnel, and its TLS takes the `net_http:` trust and client certificate settings (`ca_file`, `ca_path`, `cert_store`, `cert`, `key`, and the version and cipher settings). Certificate and hostname checks are always on. A transport of your own can say what sessions take by answering `session_options` with `{proxy:, tls:}`; without it the environment's proxy and the default trust apply.
893
+
894
+ ## Development
895
+
896
+ ```sh
897
+ bundle install
898
+ bundle exec rake test
899
+ DECISIONNODE_CONFORMANCE_DIR=/path/to/decision-node-conformance bundle exec rake conformance
900
+ ```
901
+
902
+ The conformance tests run the language-neutral DecisionNode conformance suite (version 1.27) against its mock server. They need `DECISIONNODE_CONFORMANCE_DIR`: without it, they fail rather than skip.
903
+
904
+ This gem implements version 1.3 of the DecisionNode SDK surface (`DecisionNode::SPEC_VERSION`).
905
+
906
+ ## License
907
+
908
+ MIT. See [LICENSE](LICENSE).