ruby_decision_model 0.0.1 → 0.2.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40caeb94a3a1a8365e7719753591173b6a8b89f8b0431579ac1df3f6b731caeb
4
- data.tar.gz: 3fd04d44ed5083947c79921aa2fe2c05e9616294104e351ffbea3a47448e7dd7
3
+ metadata.gz: f1c6972a68e2a0545795663a4c792603a692eb65b609d630ce2754e9f032d6b2
4
+ data.tar.gz: 4252b768aa5f7ab6e16eee20ef09ca16923fdf2b192eb21a0fb79f06f51f8b42
5
5
  SHA512:
6
- metadata.gz: dc8ba08eaf6fa210ef58809c7b3e8e585e835dc4bbaa09b7d65956b39d2168cd47d4812dabe7a6c72420fa93ebc0d9f2716e9d33a17d076f0a709ab5a573bd0c
7
- data.tar.gz: 47260e6b68ae8bf8dafec39aae852460281d4423285116f33f8a097da0556e2f0a52327503964310018561e69a3f3cd66fc9b9cb5d51f2dbca6c1bd8c5f9a7c1
6
+ metadata.gz: 69abd24fe4a39391791c183b0880a8c909e8521a5155c7d1ac92be380780572a5d23423ed32cb9b1267f2f110fbe9a9958982dca5edd2486f4b43a9e3e0cb1d6
7
+ data.tar.gz: 40d47a12bf922d452ad350180d2d1fbac189a16f2a732f3885784a7f71438e30a7e8dd7b75b875e5eeb8882ffc36a22c214177c287d13622a329322edc1b6030
data/CHANGELOG.md CHANGED
@@ -1,5 +1,109 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-10-06
4
+
5
+ The decision models that shipped after Jev, behind the same `Client`.
6
+
7
+ - Added providers `:openai` (OpenAI Decisions API, `gpt-6-luna`),
8
+ `:cloudflare` (Clef and Clef-flash on Workers AI), `:perplexity`
9
+ (`pplx-decider-v1.1-27b` and `pplx-decider-v1-27b`), `:databricks`
10
+ (`ai_decide` over REST), and `:system_one` (any server speaking
11
+ `/v1/systemone`: Ollama, the autojev server, strands-decider, and others).
12
+ - The OpenAI provider translates both ways between System One and OpenAI's
13
+ wire format. Questions, answers, and `Answers::*` types are unchanged for
14
+ callers. A noul with true/false criteria goes out as a boolean choice and
15
+ comes back as a noul.
16
+ - `Client#ask` takes `images:`, an array of base64 data URLs. Each provider
17
+ places them where its API expects. Providers that don't read images raise
18
+ `RequestError` before sending. Added `RubyDecisionModel::Images.data_url`
19
+ and `.from_file`.
20
+ - Refused questions (OpenAI) raise `MissingAnswers` as before, with the new
21
+ `#refused` listing the ids the provider declined.
22
+ - Noul `probabilities` are always `{"true" => p, "false" => 1 - p}`. Jev,
23
+ Clef, pplx-decider, and most System One servers send only the probability,
24
+ so the client fills in the split. A noul with junk `probabilities` now gets
25
+ the split instead of `{}`, and a noul outside 0..1 raises
26
+ `InvalidResponse`.
27
+ - State or questions that cannot be encoded as JSON (invalid UTF-8, `NaN`,
28
+ nesting deeper than 100 levels) raise `RequestError`, with the original
29
+ error on `#cause`.
30
+ - `RUBY_DECISION_MODEL_PROVIDER` names a provider from the environment ahead
31
+ of key detection (any case; `-` reads as `_`), and
32
+ `Client.new(api_key: ...)` sends that key to the named provider rather
33
+ than OpenRouter. `SYSTEM_ONE_BASE_URL` joins detection after
34
+ `TYPESAFE_API_KEY` and `OPENROUTER_API_KEY`. Keys like `OPENAI_API_KEY` do
35
+ not select a provider by themselves.
36
+ - New aliases. On OpenRouter: `luna` and `gpt-6-luna` resolve to
37
+ `openai/gpt-6-luna-decisions`, `clef` and `clef-flash` to the
38
+ `cloudflare/` slugs, and `pplx-decider` to
39
+ `perplexity/pplx-decider-v1-27b`. Native providers accept the OpenRouter
40
+ slugs for their own models. Typesafe accepts `~typesafe/jev-latest`.
41
+ - Each provider names its own request id header: `x-request-id` on OpenAI
42
+ and Perplexity, `cf-ray` on Cloudflare, and `x-typesafe-request-id`
43
+ everywhere else, as in 0.1.0.
44
+ - `timeout:` now defaults to `nil`, meaning the provider's read timeout: 5
45
+ seconds for Typesafe, OpenRouter, and Cloudflare, 30 for OpenAI,
46
+ Perplexity, Databricks, and System One servers, with a 5 second open
47
+ timeout. Passing a number sets both, as before. The write timeout, which
48
+ bounds image uploads, follows the read timeout, and a write timeout is
49
+ retried and raised as `TimeoutError` like the other two. In 0.1.0 an
50
+ explicit `timeout: nil` meant no limit at all; it now means these
51
+ defaults. Added `Client#open_timeout`.
52
+ - `ApiError` messages end with the vendor's reason when the error body has
53
+ one, for example `api error (status 400): Invalid model 'x'.` The client
54
+ reads OpenAI, Perplexity, and OpenRouter `error.message`, Cloudflare
55
+ `errors[].message`, FastAPI `detail[].msg` (Typesafe), and Databricks
56
+ `message`. The API key is masked if a vendor echoes it, and the text is
57
+ capped at 500 characters. `#body` is unchanged.
58
+ - API keys are stripped before they go into the `Authorization` header, so
59
+ a key read from a file with its trailing newline works.
60
+ - Added `rake smoke`, an opt-in live check against every provider whose keys
61
+ are set. CI never runs it.
62
+ - Provider hooks for authors: `normalize_response`, `supports_images?`,
63
+ `request_id_header`, `requires_api_key?`, `default_timeout`,
64
+ `error_message`, `validate!`, `configured?`, and `url(model)`. Base sends
65
+ `Authorization` only when a key is present. Providers written against
66
+ 0.1.0 keep working: a `url` override without the model argument is still
67
+ called without it.
68
+
69
+ ## 0.1.0 - 2026-09-18
70
+
71
+ Provider-neutral release. One `Client`, two providers behind it.
72
+
73
+ - Added `RubyDecisionModel::Providers` with `Base`, `OpenRouter`, and
74
+ `Typesafe`. A provider owns base URL, endpoint path, auth, default model,
75
+ model aliases, and usage parsing. `Client` delegates to it.
76
+ - `Client.new` takes `provider:` (`:open_router`, `:typesafe`, or a
77
+ `Providers::Base` instance). With no provider and no `api_key:`, the
78
+ environment decides: `TYPESAFE_API_KEY` first, then `OPENROUTER_API_KEY`,
79
+ otherwise `ConfigurationError` naming both. `api_key:` alone still means
80
+ OpenRouter. `model:` defaults to the provider's model. `base_url:` overrides
81
+ the provider base. `client.provider` and `client.model` are readable.
82
+ - Added `RubyDecisionModel.client`, a memoized default client, and
83
+ `RubyDecisionModel.client = nil` to reset it.
84
+ - Model aliases: `jev` and `jev-latest` resolve to `typesafe/jev-1.13` on
85
+ OpenRouter; `typesafe/jev-1.13` and `jev` resolve to `jev-latest` on
86
+ Typesafe. Other names pass through.
87
+ - Both providers send `User-Agent: ruby_decision_model/<version>`.
88
+ - Added `RubyDecisionModel::RetryPolicy` matching the official Typesafe SDKs:
89
+ `max_retries: 2`, exponential backoff from 0.5s capped at 5s with 25%
90
+ jitter, retry on 408, 429, and 5xx, `Retry-After` and `retry-after-ms`
91
+ honored up to 60s, connection errors and timeouts retried, and a
92
+ `total_timeout: 30.0` budget across attempts and delays. `Client` accepts
93
+ `retry:` as a policy or a Hash of overrides. Jitter uses an injectable
94
+ `random:`. Invalid settings raise `ConfigurationError` at construction.
95
+ - Transport contract now returns `[status, body, headers]`. Two-element
96
+ returns are still accepted.
97
+ - Added `Response#request_id` (from `x-typesafe-request-id`, nil on
98
+ OpenRouter) and `Response#nouls`, `#choices`, `#scores`.
99
+ - Added `UnprocessableEntity` (422) and `Overloaded` (529). `ApiError` now
100
+ carries `#headers`.
101
+ - Usage `cost` is `nil` on Typesafe, which does not report it.
102
+ - `Client::DEFAULT_BASE_URL`, `DEFAULT_MODEL`, `MAX_ATTEMPTS`, `RETRYABLE_STATUSES`,
103
+ and `RETRYABLE_EXCEPTIONS` remain as compatibility aliases. They now read from
104
+ the OpenRouter provider and the default `RetryPolicy`, so `MAX_ATTEMPTS` is 3
105
+ and `RETRYABLE_STATUSES` covers 408, 429, and every 5xx.
106
+
3
107
  ## 0.0.1 - 2026-09-18
4
108
 
5
109
  Initial release. Client and question builders for OpenRouter's `/decisions`
data/README.md CHANGED
@@ -1,8 +1,14 @@
1
1
  # ruby_decision_model
2
2
 
3
- Decision models answer typed questions about a state with calibrated probabilities,
4
- instead of generating text. This gem is a dependency-free Ruby client for them,
5
- starting with OpenRouter's `/decisions` endpoint and Typesafe Jev.
3
+ The decision-model interface for Ruby. Decision models answer typed questions
4
+ about a state with calibrated probabilities instead of generating text. This gem
5
+ talks to them through one `Client` with a provider behind it. OpenRouter is the
6
+ default. Typesafe (Jev), OpenAI (gpt-6-luna), Cloudflare (Clef), Perplexity
7
+ (pplx-decider), and Databricks (`ai_decide`) each have a native provider, and
8
+ any server that speaks the System One API (Ollama, strands-decider, the
9
+ autojev server) works through `:system_one`. Your questions and answers keep
10
+ one shape whichever provider serves them. No runtime dependencies beyond the
11
+ standard library.
6
12
 
7
13
  ## Install
8
14
 
@@ -10,12 +16,12 @@ starting with OpenRouter's `/decisions` endpoint and Typesafe Jev.
10
16
  gem "ruby_decision_model"
11
17
  ```
12
18
 
13
- ## Usage
19
+ ## Quick start
14
20
 
15
21
  ```ruby
16
22
  require "ruby_decision_model"
17
23
 
18
- client = RubyDecisionModel::Client.new(api_key: ENV["OPENROUTER_API_KEY"])
24
+ client = RubyDecisionModel::Client.new
19
25
 
20
26
  response = client.ask(
21
27
  state: { title: "Server returns 500 on checkout", reporter: "support" },
@@ -30,20 +36,338 @@ response = client.ask(
30
36
 
31
37
  response["urgent"].noul # => 0.87
32
38
  response["severity"].score # => 2.4
33
- response.usage.cost # => 0.0012
39
+ response.usage.input_tokens # => 120
34
40
  ```
35
41
 
42
+ `Client.new` with no arguments reads the environment.
43
+ `RUBY_DECISION_MODEL_PROVIDER` names a provider outright (`openai`,
44
+ `cloudflare`, and so on, in any case, with `-` or `_`), and an `api_key:` passed without `provider:` goes
45
+ to that provider. Without it, `TYPESAFE_API_KEY` selects Typesafe,
46
+ then `OPENROUTER_API_KEY` selects OpenRouter, then `SYSTEM_ONE_BASE_URL`
47
+ selects a System One server. General-purpose credentials such as
48
+ `OPENAI_API_KEY` or `CLOUDFLARE_API_TOKEN` never pick a provider on their own,
49
+ since plenty of apps hold them for other reasons. With nothing usable set,
50
+ `Client.new` raises `ConfigurationError` naming the variables it checked.
51
+ `RubyDecisionModel.client` memoizes one such default client; assign `nil` to
52
+ reset it.
53
+
54
+ Switching vendors is a configuration change:
55
+
56
+ ```ruby
57
+ RubyDecisionModel::Client.new(provider: :openai) # gpt-6-luna
58
+ RubyDecisionModel::Client.new(provider: :perplexity) # pplx-decider-v1.1-27b
59
+ RubyDecisionModel::Client.new(provider: :open_router, model: "clef")
60
+ ```
61
+
62
+ ## Providers
63
+
64
+ ### OpenRouter (default)
65
+
66
+ ```ruby
67
+ # ENV["OPENROUTER_API_KEY"]
68
+ client = RubyDecisionModel::Client.new(provider: :open_router)
69
+
70
+ # or pass the key directly; api_key: alone means OpenRouter
71
+ # unless RUBY_DECISION_MODEL_PROVIDER names another provider
72
+ client = RubyDecisionModel::Client.new(api_key: "sk-or-...")
73
+ ```
74
+
75
+ Requests go to `https://openrouter.ai/api/alpha/decisions`. The default model is
76
+ `typesafe/jev-1.13`. Usage reports `input_tokens`, `output_tokens`, and `cost`.
77
+
78
+ ### Typesafe native API
79
+
80
+ ```ruby
81
+ # ENV["TYPESAFE_API_KEY"]
82
+ client = RubyDecisionModel::Client.new(provider: :typesafe)
83
+ ```
84
+
85
+ Requests go to `https://api.typesafe.ai/v1/systemone`. The default model is
86
+ `jev-latest`. Usage reports `input_tokens` and `output_tokens`; `cost` is `nil`.
87
+ Typesafe returns an `x-typesafe-request-id` header, exposed as
88
+ `response.request_id`. Quote it when reporting a problem to Typesafe.
89
+
90
+ ### OpenAI Decisions API
91
+
92
+ ```ruby
93
+ # ENV["OPENAI_API_KEY"]
94
+ client = RubyDecisionModel::Client.new(provider: :openai)
95
+ ```
96
+
97
+ Requests go to `https://api.openai.com/v1/decisions`. The API is in public beta
98
+ as of October 2026, so its wire format may still change. The default model is
99
+ `gpt-6-luna`. OpenAI's wire format differs from System One, so the
100
+ provider translates both ways and your questions stay the same:
101
+
102
+ - `state` becomes `input`. A String passes through as is. Anything else is
103
+ sent as JSON text, because the API takes no structured input.
104
+ - `noul` becomes a `predicate`. A noul with `true`/`false` criteria becomes a
105
+ boolean `choice` so the descriptions reach the model, and the answer comes
106
+ back as a noul.
107
+ - Choice criteria become `choices` and score criteria become `levels`.
108
+ - Answers arrive as an array and are rebuilt keyed by question id, with
109
+ probability Hashes and a score `legend` built from your criteria.
110
+
111
+ When the model declines a question the client raises `MissingAnswers` with
112
+ that id in `#refused`; the other answers are on `#answers`. Usage reports
113
+ tokens with no cost. OpenAI's response carries no id today, so `response.id`
114
+ is nil unless OpenAI starts sending one. `response.request_id` reads
115
+ `x-request-id`.
116
+
117
+ ### Cloudflare Workers AI (Clef)
118
+
119
+ ```ruby
120
+ # ENV["CLOUDFLARE_API_TOKEN"] (or CLOUDFLARE_AUTH_TOKEN) and ENV["CLOUDFLARE_ACCOUNT_ID"]
121
+ client = RubyDecisionModel::Client.new(provider: :cloudflare, model: "clef-flash")
122
+
123
+ # or configure the provider directly
124
+ provider = RubyDecisionModel::Providers::Cloudflare.new(api_key: "...", account_id: "...")
125
+ client = RubyDecisionModel::Client.new(provider: provider)
126
+ ```
127
+
128
+ Requests go to
129
+ `https://api.cloudflare.com/client/v4/accounts/<account_id>/ai/run/@cf/cloudflare/<model>`.
130
+ The default model is `clef`; `clef-flash` is the smaller, faster one. The body
131
+ is System One. The provider unwraps Cloudflare's `{"result": ...}` envelope,
132
+ and a body with `"success": false` raises `InvalidResponse` carrying
133
+ Cloudflare's error text. The account id and model end up in the URL path, so
134
+ both are checked when the client is built. The account id may hold letters,
135
+ digits, `-`, and `_`; surrounding whitespace is stripped. The model must start
136
+ with a letter or digit and may also hold `.`, `-`, and `_`. Anything else
137
+ raises `ConfigurationError`.
138
+ Usage reports tokens with no cost, and `response.request_id` is the `cf-ray`
139
+ header.
140
+
141
+ ### Perplexity (pplx-decider)
142
+
143
+ ```ruby
144
+ # ENV["PERPLEXITY_API_KEY"]
145
+ client = RubyDecisionModel::Client.new(provider: :perplexity)
146
+ ```
147
+
148
+ Requests go to `https://api.perplexity.ai/v1/decisions`. The default model is
149
+ `pplx-decider-v1.1-27b`; `pplx-decider-v1-27b` also works. The body is System
150
+ One. `response.request_id` reads `x-request-id`.
151
+
152
+ ### Databricks `ai_decide`
153
+
154
+ ```ruby
155
+ # ENV["DATABRICKS_HOST"] and ENV["DATABRICKS_TOKEN"]
156
+ client = RubyDecisionModel::Client.new(provider: :databricks)
157
+ ```
158
+
159
+ Requests go to `<host>/api/2.0/ai-functions/ai-decide`. `ai_decide` is a
160
+ Databricks beta: a workspace admin has to enable it, and its REST shape may
161
+ still change. The workspace serves one managed model, so
162
+ `client.model` is nil and passing `model:` raises `ConfigurationError`. The
163
+ provider reads answers from the `response` wrapper. Databricks reports no
164
+ usage, so every usage field is nil.
165
+
166
+ ### System One servers (Ollama, local models)
167
+
168
+ ```ruby
169
+ # ENV["SYSTEM_ONE_BASE_URL"], and ENV["SYSTEM_ONE_API_KEY"] if the server wants one
170
+ client = RubyDecisionModel::Client.new(provider: :system_one, base_url: "http://localhost:11434", model: "nimble")
171
+ ```
172
+
173
+ Anything that serves Typesafe's API at `/v1/systemone` works here: the autojev
174
+ server shipped with pplx-decider's weights, strands-decider's local server,
175
+ and hosted lookalikes. Pydantic AI's docs say Ollama 0.35 and later serves it
176
+ too, with models such as `nimble` and `tev1`. The base URL has no
177
+ `/v1` suffix and needs its `http://` or `https://` and a host. It is checked
178
+ when the first request is built rather than in `Client.new`, so a `base_url:`
179
+ passed to the client can replace an unusable `SYSTEM_ONE_BASE_URL`; a bad one
180
+ raises `ConfigurationError` from `ask`. The API key and model are optional; without a key no
181
+ `Authorization` header is sent, and without a model the body has no `model`
182
+ field. The environment variable names match Pydantic AI's.
183
+
184
+ ### Options
185
+
186
+ ```ruby
187
+ RubyDecisionModel::Client.new(
188
+ provider: :typesafe, # a name from Providers.names, or a Providers::Base instance
189
+ api_key: nil, # overrides the provider's env var
190
+ model: nil, # nil means the provider default; see aliases below
191
+ base_url: nil, # overrides the provider base URL
192
+ timeout: nil, # seconds; nil means the provider's read default and a 5s open timeout
193
+ retry: { max_retries: 2 }, # RetryPolicy or a Hash of overrides
194
+ transport: nil # see Transport
195
+ )
196
+
197
+ client.provider # => #<RubyDecisionModel::Providers::Typesafe ...>
198
+ client.model # => "jev-latest" (resolved after aliasing)
199
+ ```
200
+
201
+ Every provider sends `User-Agent: ruby_decision_model/<version>`.
202
+
203
+ The default read timeout is 5 seconds for Typesafe, OpenRouter, and
204
+ Cloudflare, whose models answer in well under a second. OpenAI, Perplexity,
205
+ Databricks, and System One servers default to 30, since Perplexity documents
206
+ responses of up to 23 seconds on large inputs and a local server may load the
207
+ model on the first request. Connecting gets 5 seconds either way. A number
208
+ passed as `timeout:` sets both, as in 0.1.0. Through OpenRouter, pass a
209
+ longer `timeout:` yourself for large inputs to slower models.
210
+
211
+ ### Model aliases
212
+
213
+ Each provider resolves a few friendly names to its own canonical model name,
214
+ so one short name follows a model from OpenRouter to its vendor's API and
215
+ back. OpenRouter slugs also resolve on the vendor's own provider. Anything
216
+ not listed passes through untouched, which is how you reach models without an
217
+ alias, such as `liquid/d1` on OpenRouter. The `model` field on a response is
218
+ whatever the provider returned.
219
+
220
+ | You pass | OpenRouter sends | Native provider sends |
221
+ | --- | --- | --- |
222
+ | `nil` | `typesafe/jev-1.13` | that provider's default |
223
+ | `"jev"`, `"jev-latest"` | `typesafe/jev-1.13` | `jev-latest` (Typesafe) |
224
+ | `"typesafe/jev-1.13"`, `"~typesafe/jev-latest"` | as given | `jev-latest` (Typesafe) |
225
+ | `"luna"`, `"gpt-6-luna"` | `openai/gpt-6-luna-decisions` | `gpt-6-luna` (OpenAI) |
226
+ | `"openai/gpt-6-luna-decisions"`, `"openai/gpt-6-luna"`, `"gpt-6-luna-decisions"` | as given | `gpt-6-luna` (OpenAI) |
227
+ | `"clef"`, `"clef-flash"` | `cloudflare/clef`, `cloudflare/clef-flash` | as given (Cloudflare) |
228
+ | `"cloudflare/clef"`, `"cloudflare/clef-flash"` | as given | `clef`, `clef-flash` (Cloudflare) |
229
+ | `"@cf/cloudflare/clef"`, `"@cf/cloudflare/clef-flash"` | as given | `clef`, `clef-flash` (Cloudflare) |
230
+ | `"pplx-decider"` | `perplexity/pplx-decider-v1-27b` | `pplx-decider-v1.1-27b` (Perplexity) |
231
+ | `"pplx-decider-v1-27b"` | `perplexity/pplx-decider-v1-27b` | as given (Perplexity) |
232
+ | `"perplexity/pplx-decider-v1-27b"` | as given | `pplx-decider-v1-27b` (Perplexity) |
233
+ | anything else | as given | as given |
234
+
235
+ OpenRouter carries pplx-decider v1 only, so `"pplx-decider"` means v1 there
236
+ and v1.1 on Perplexity's own API.
237
+
238
+ ### Writing a provider
239
+
240
+ Subclass `RubyDecisionModel::Providers::Base` and define `name`, `env_var`,
241
+ `default_base_url`, `endpoint_path`, and `default_model`. Optional hooks:
242
+ `aliases`, `reports_cost?`, `supports_images?`, `request_id_header`,
243
+ `requires_api_key?`, `default_timeout`, `error_message`, and `validate!`. When the wire format differs from
244
+ System One, override `request_body` to encode and `normalize_response` to turn
245
+ the parsed body back into System One answers keyed by question id; the
246
+ OpenAI provider shows both directions. Override `url(model)` when the model
247
+ belongs in the path. Pass an instance as `provider:`.
248
+
249
+ ## Questions and answers
250
+
251
+ Three question types, built with `RubyDecisionModel::Questions`:
252
+
253
+ ```ruby
254
+ Questions.noul("Is this spam?") # yes/no probability
255
+ Questions.choice("Which team?", criteria: { "billing" => "...", "auth" => "..." }) # up to 255 options
256
+ Questions.score("How severe?", criteria: ["cosmetic", "minor", "major"]) # 2 to 10 levels
257
+ ```
258
+
259
+ Answers come back typed: `Answers::Noul` (`noul`, `probabilities`),
260
+ `Answers::Choice` (`choice`, `confidence`, `probabilities`), and
261
+ `Answers::Score` (`score`, `confidence`, `probabilities`, `legend`).
262
+ `response.nouls`, `response.choices`, and `response.scores` return the answers
263
+ of one type keyed the same way as `response.answers`.
264
+
265
+ Score `probabilities` and `legend` are keyed by the wire's string level keys
266
+ (`"0"`, `"1"`, ...), not by the criteria labels. Choice `probabilities` sum to
267
+ approximately 1; treat them as calibrated, not normalized. Noul
268
+ `probabilities` are always `{"true" => p, "false" => 1 - p}`. Most providers
269
+ send only the probability, and the client fills in the split.
270
+
271
+ ## Images
272
+
273
+ OpenAI, Cloudflare, Perplexity, and System One servers read images. Pass them
274
+ as base64 data URLs; no provider fetches a remote URL.
275
+
276
+ ```ruby
277
+ photo = RubyDecisionModel::Images.from_file("damage.jpg")
278
+ # or RubyDecisionModel::Images.data_url(bytes, content_type: "image/png")
279
+
280
+ client.ask(
281
+ state: "Customer says the screen arrived cracked.",
282
+ questions: { "damaged" => RubyDecisionModel::Questions.noul("Is there visible damage?") },
283
+ images: [photo]
284
+ )
285
+ ```
286
+
287
+ Each provider puts them where its API expects: `input_image` parts for
288
+ OpenAI, the `images` field for Cloudflare and System One servers, and
289
+ `image_url` parts inside `state` for Perplexity. Typesafe, OpenRouter, and
290
+ Databricks don't take images, so the client raises `RequestError` before
291
+ sending. Size and count limits vary by vendor and are enforced server side.
292
+
293
+ ## Retries
294
+
295
+ Retry behaviour follows the official Typesafe SDKs and lives in
296
+ `RubyDecisionModel::RetryPolicy`. Pass a policy or a Hash of overrides as
297
+ `retry:`.
298
+
299
+ | Option | Default | Meaning |
300
+ | --- | --- | --- |
301
+ | `max_retries` | `2` | Retries after the initial attempt |
302
+ | `backoff_initial` | `0.5` | First backoff in seconds, doubling each retry |
303
+ | `backoff_max` | `5.0` | Backoff ceiling in seconds |
304
+ | `backoff_jitter` | `0.25` | Fraction of the backoff randomly subtracted |
305
+ | `http_statuses` | `[408, 429] + (500..599)` | Statuses that trigger a retry |
306
+ | `respect_retry_after` | `true` | Honor `Retry-After` and `retry-after-ms` |
307
+ | `max_retry_after` | `60.0` | Ceiling for a server-supplied delay |
308
+ | `retry_connection_errors` | `true` | Retry socket and connection failures |
309
+ | `retry_timeouts` | `true` | Retry open, read, and write timeouts |
310
+ | `total_timeout` | `30.0` | Budget in seconds across attempts and delays; `nil` disables |
311
+
312
+ When the next delay would push past `total_timeout`, the client stops and
313
+ raises the last error instead of sleeping. The budget governs whether another
314
+ attempt starts; an attempt already in flight still runs to its own `timeout`.
315
+ With the 30 second read timeout that OpenAI, Perplexity, Databricks, and
316
+ System One servers default to, an attempt that times out uses the whole
317
+ default budget, so it is not retried. Pass `retry: { total_timeout: 90.0 }`
318
+ if you would rather wait for a retry.
319
+
320
+ Invalid settings (a negative duration, a non-integer `max_retries`, a jitter
321
+ outside 0..1, a NaN budget) raise `ConfigurationError` when the client is built.
322
+
323
+ ```ruby
324
+ RubyDecisionModel::Client.new(retry: { max_retries: 4, total_timeout: 60.0 })
325
+ RubyDecisionModel::Client.new(retry: RubyDecisionModel::RetryPolicy.new(max_retries: 0))
326
+ ```
327
+
328
+ ## Transport
329
+
330
+ The client uses `Net::HTTP` by default. Inject `transport:` with any callable
331
+ that accepts `url:`, `headers:`, `body:` and returns
332
+ `[status, body_string, headers_hash]`. A two-element `[status, body_string]`
333
+ return is still accepted and treated as having no headers, which means no
334
+ `Retry-After` support and a nil `request_id`.
335
+
36
336
  ## Errors
37
337
 
38
338
  | Error | Meaning |
39
339
  | --- | --- |
40
- | `ConfigurationError` | Missing api_key, model, or base_url |
41
- | `RequestError` | Questions hash was empty |
42
- | `TransportError` (`TimeoutError`) | Network or timeout failure |
43
- | `ApiError` (`Unauthorized`, `PayloadTooLarge`, `RateLimited`) | Non-2xx response, carries `#status` and `#body` |
44
- | `InvalidResponse` | Body wasn't JSON, wasn't a Hash, or an answer was malformed |
45
- | `MissingAnswers` | One or more question ids came back missing or wrong-typed, carries `#missing` |
340
+ | `ConfigurationError` | No provider could be resolved, a missing key or setting (api_key, account_id, base_url), unknown provider, a `model:` Databricks can't take, or bad `retry:` value |
341
+ | `RequestError` | Questions hash was empty, images were not data URLs or went to a provider that doesn't read them, or state or questions could not be encoded as JSON (original error on `#cause`) |
342
+ | `TransportError` (`TimeoutError`) | Network or timeout failure after retries, carries `#cause_error` |
343
+ | `ApiError` | Non-2xx response, carries `#status`, `#body`, and `#headers`. The message ends with the vendor's own reason when the body has one |
344
+ | `Unauthorized` | 401 |
345
+ | `PayloadTooLarge` | 413 |
346
+ | `UnprocessableEntity` | 422 (never retried) |
347
+ | `RateLimited` | 429 (retried) |
348
+ | `Overloaded` | 529 (retried) |
349
+ | `InvalidResponse` | Body wasn't JSON, wasn't a Hash, or an answer was malformed (including a noul outside 0..1 or an id answered twice) |
350
+ | `MissingAnswers` | One or more question ids came back missing, wrong-typed, or refused. Carries `#missing`, `#refused` (the subset the provider declined), and `#answers` |
351
+
352
+ Status: 0.2.0, API may change.
353
+
354
+ The companion gem `decide` builds decisions and verdicts on top of this client.
355
+
356
+ ## Releasing
357
+
358
+ Publishing runs through RubyGems trusted publishing, so no API key is stored
359
+ anywhere. To ship a version:
46
360
 
47
- Status: 0.0.1, API may change.
361
+ 1. Bump `lib/ruby_decision_model/version.rb`.
362
+ 2. Add the version to `CHANGELOG.md`.
363
+ 3. Run `rake smoke` with whatever provider keys you have. It makes one live
364
+ request per configured provider and prints the answers. CI never runs it.
365
+ `TARGETS=open_router:clef,open_router:luna` picks provider and model
366
+ pairs, and `SMOKE_IMAGE=photo.png` adds an image where supported.
367
+ 4. Merge to `main`. The Release workflow runs the suite, builds the gem with
368
+ `gem build --strict`, checks the built gem carries every file under
369
+ `lib/`, and pushes it. A version already on RubyGems is skipped, so the
370
+ workflow is safe to re-run.
48
371
 
49
- The companion gem `ruby_dm` builds decisions and verdicts on top of this client.
372
+ The same workflow can be started by hand from the Actions tab or with
373
+ `gh workflow run release.yml`.