ruby_decision_model 0.1.0 → 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: 2bea0fe1efb5046f07952098626ed15f1dcd28fc80f56fe1810b3a7cd1050bab
4
- data.tar.gz: ebd51acc65df870cce1cd567033116d262424a2e38f3a86c41403c329a0dea31
3
+ metadata.gz: f1c6972a68e2a0545795663a4c792603a692eb65b609d630ce2754e9f032d6b2
4
+ data.tar.gz: 4252b768aa5f7ab6e16eee20ef09ca16923fdf2b192eb21a0fb79f06f51f8b42
5
5
  SHA512:
6
- metadata.gz: 5f883f0e2cc526cdbf75b12632359670a70795caa030770c69b9c7af77ebc3cbe2054416f4900e3a5ed3773a507dc53246d1389e04714bc9e45d093b8b7184f8
7
- data.tar.gz: b4fbb04f181c347fc28ae0c040da2f074d9ada571581cdb2c8d96097e43391d8955ab370dd3f09451ae1fe08a666b68881503ddadddca5979abf633f1b89faef
6
+ metadata.gz: 69abd24fe4a39391791c183b0880a8c909e8521a5155c7d1ac92be380780572a5d23423ed32cb9b1267f2f110fbe9a9958982dca5edd2486f4b43a9e3e0cb1d6
7
+ data.tar.gz: 40d47a12bf922d452ad350180d2d1fbac189a16f2a732f3885784a7f71438e30a7e8dd7b75b875e5eeb8882ffc36a22c214177c287d13622a329322edc1b6030
data/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
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
+
3
69
  ## 0.1.0 - 2026-09-18
4
70
 
5
71
  Provider-neutral release. One `Client`, two providers behind it.
data/README.md CHANGED
@@ -2,9 +2,13 @@
2
2
 
3
3
  The decision-model interface for Ruby. Decision models answer typed questions
4
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 by
6
- default, Typesafe's native API as a second door, more providers as labs ship
7
- them. No runtime dependencies beyond the standard library.
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.
8
12
 
9
13
  ## Install
10
14
 
@@ -35,10 +39,25 @@ response["severity"].score # => 2.4
35
39
  response.usage.input_tokens # => 120
36
40
  ```
37
41
 
38
- `Client.new` with no arguments reads the environment: `TYPESAFE_API_KEY` selects
39
- Typesafe, otherwise `OPENROUTER_API_KEY` selects OpenRouter. With neither set it
40
- raises `ConfigurationError` naming both. `RubyDecisionModel.client` memoizes one
41
- such default client; assign `nil` to reset it.
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
+ ```
42
61
 
43
62
  ## Providers
44
63
 
@@ -48,7 +67,8 @@ such default client; assign `nil` to reset it.
48
67
  # ENV["OPENROUTER_API_KEY"]
49
68
  client = RubyDecisionModel::Client.new(provider: :open_router)
50
69
 
51
- # or pass the key directly; api_key: alone still means OpenRouter
70
+ # or pass the key directly; api_key: alone means OpenRouter
71
+ # unless RUBY_DECISION_MODEL_PROVIDER names another provider
52
72
  client = RubyDecisionModel::Client.new(api_key: "sk-or-...")
53
73
  ```
54
74
 
@@ -65,18 +85,111 @@ client = RubyDecisionModel::Client.new(provider: :typesafe)
65
85
  Requests go to `https://api.typesafe.ai/v1/systemone`. The default model is
66
86
  `jev-latest`. Usage reports `input_tokens` and `output_tokens`; `cost` is `nil`.
67
87
  Typesafe returns an `x-typesafe-request-id` header, exposed as
68
- `response.request_id` (nil on OpenRouter). Quote it when reporting a problem
69
- to Typesafe.
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.
70
183
 
71
184
  ### Options
72
185
 
73
186
  ```ruby
74
187
  RubyDecisionModel::Client.new(
75
- provider: :typesafe, # :open_router, :typesafe, or a Providers::Base instance
188
+ provider: :typesafe, # a name from Providers.names, or a Providers::Base instance
76
189
  api_key: nil, # overrides the provider's env var
77
190
  model: nil, # nil means the provider default; see aliases below
78
191
  base_url: nil, # overrides the provider base URL
79
- timeout: 5, # open and read timeout in seconds
192
+ timeout: nil, # seconds; nil means the provider's read default and a 5s open timeout
80
193
  retry: { max_retries: 2 }, # RetryPolicy or a Hash of overrides
81
194
  transport: nil # see Transport
82
195
  )
@@ -85,28 +198,53 @@ client.provider # => #<RubyDecisionModel::Providers::Typesafe ...>
85
198
  client.model # => "jev-latest" (resolved after aliasing)
86
199
  ```
87
200
 
88
- Both providers send `User-Agent: ruby_decision_model/<version>`.
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.
89
210
 
90
211
  ### Model aliases
91
212
 
92
- Each provider resolves a few friendly names to its own canonical model name.
93
- Anything not listed passes through untouched. The `model` field on a response
94
- is whatever the provider returned.
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.
95
219
 
96
- | You pass | OpenRouter sends | Typesafe sends |
220
+ | You pass | OpenRouter sends | Native provider sends |
97
221
  | --- | --- | --- |
98
- | `nil` | `typesafe/jev-1.13` | `jev-latest` |
99
- | `"jev"` | `typesafe/jev-1.13` | `jev-latest` |
100
- | `"jev-latest"` | `typesafe/jev-1.13` | `jev-latest` |
101
- | `"typesafe/jev-1.13"` | `typesafe/jev-1.13` | `jev-latest` |
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) |
102
233
  | anything else | as given | as given |
103
234
 
235
+ OpenRouter carries pplx-decider v1 only, so `"pplx-decider"` means v1 there
236
+ and v1.1 on Perplexity's own API.
237
+
104
238
  ### Writing a provider
105
239
 
106
240
  Subclass `RubyDecisionModel::Providers::Base` and define `name`, `env_var`,
107
- `default_base_url`, `endpoint_path`, `default_model`, and optionally `aliases`
108
- and `reports_cost?`. Override `headers`, `request_body`, or `usage` when the
109
- wire format differs. Pass an instance as `provider:`.
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:`.
110
248
 
111
249
  ## Questions and answers
112
250
 
@@ -126,7 +264,31 @@ of one type keyed the same way as `response.answers`.
126
264
 
127
265
  Score `probabilities` and `legend` are keyed by the wire's string level keys
128
266
  (`"0"`, `"1"`, ...), not by the criteria labels. Choice `probabilities` sum to
129
- approximately 1; treat them as calibrated, not normalized.
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.
130
292
 
131
293
  ## Retries
132
294
 
@@ -144,12 +306,16 @@ Retry behaviour follows the official Typesafe SDKs and lives in
144
306
  | `respect_retry_after` | `true` | Honor `Retry-After` and `retry-after-ms` |
145
307
  | `max_retry_after` | `60.0` | Ceiling for a server-supplied delay |
146
308
  | `retry_connection_errors` | `true` | Retry socket and connection failures |
147
- | `retry_timeouts` | `true` | Retry open and read timeouts |
309
+ | `retry_timeouts` | `true` | Retry open, read, and write timeouts |
148
310
  | `total_timeout` | `30.0` | Budget in seconds across attempts and delays; `nil` disables |
149
311
 
150
312
  When the next delay would push past `total_timeout`, the client stops and
151
313
  raises the last error instead of sleeping. The budget governs whether another
152
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.
153
319
 
154
320
  Invalid settings (a negative duration, a non-integer `max_retries`, a jitter
155
321
  outside 0..1, a NaN budget) raise `ConfigurationError` when the client is built.
@@ -171,18 +337,37 @@ return is still accepted and treated as having no headers, which means no
171
337
 
172
338
  | Error | Meaning |
173
339
  | --- | --- |
174
- | `ConfigurationError` | No provider could be resolved, missing api_key, unknown provider, or bad `retry:` value |
175
- | `RequestError` | Questions hash was empty |
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`) |
176
342
  | `TransportError` (`TimeoutError`) | Network or timeout failure after retries, carries `#cause_error` |
177
- | `ApiError` | Non-2xx response, carries `#status`, `#body`, and `#headers` |
343
+ | `ApiError` | Non-2xx response, carries `#status`, `#body`, and `#headers`. The message ends with the vendor's own reason when the body has one |
178
344
  | `Unauthorized` | 401 |
179
345
  | `PayloadTooLarge` | 413 |
180
346
  | `UnprocessableEntity` | 422 (never retried) |
181
347
  | `RateLimited` | 429 (retried) |
182
348
  | `Overloaded` | 529 (retried) |
183
- | `InvalidResponse` | Body wasn't JSON, wasn't a Hash, or an answer was malformed |
184
- | `MissingAnswers` | One or more question ids came back missing or wrong-typed, carries `#missing` |
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` |
185
351
 
186
- Status: 0.1.0, API may change.
352
+ Status: 0.2.0, API may change.
187
353
 
188
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:
360
+
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.
371
+
372
+ The same workflow can be started by hand from the Actions tab or with
373
+ `gh workflow run release.yml`.
@@ -7,41 +7,48 @@ require "uri"
7
7
 
8
8
  module RubyDecisionModel
9
9
  class Client
10
- REQUEST_ID_HEADER = "x-typesafe-request-id"
11
-
12
10
  # Kept from 0.0.1 for callers that referenced them. They describe the
13
- # OpenRouter provider and the default RetryPolicy; prefer those directly.
11
+ # Typesafe and OpenRouter providers and the default RetryPolicy; prefer
12
+ # those directly. Each provider now names its own request id header.
13
+ REQUEST_ID_HEADER = "x-typesafe-request-id"
14
14
  DEFAULT_BASE_URL = Providers::OpenRouter.new.default_base_url
15
15
  DEFAULT_MODEL = Providers::OpenRouter.new.default_model
16
16
  MAX_ATTEMPTS = RetryPolicy.new.max_retries + 1
17
17
  RETRYABLE_STATUSES = RetryPolicy::DEFAULT_STATUSES
18
18
  RETRYABLE_EXCEPTIONS = (RetryPolicy::TIMEOUT_EXCEPTIONS + RetryPolicy::CONNECTION_EXCEPTIONS).freeze
19
19
 
20
- attr_reader :provider, :model, :retry_policy, :timeout
20
+ # Connecting is quick wherever the answer is slow, so the open timeout
21
+ # stays short unless the caller sets timeout: explicitly.
22
+ DEFAULT_OPEN_TIMEOUT = 5
21
23
 
22
- # provider: :open_router, :typesafe, or a Providers::Base instance. When
23
- # nil, api_key: alone selects OpenRouter; otherwise the
24
- # environment decides (TYPESAFE_API_KEY, then OPENROUTER_API_KEY).
24
+ attr_reader :provider, :model, :retry_policy, :timeout, :open_timeout
25
+
26
+ # provider: a name from Providers.names or a Providers::Base instance.
27
+ # When nil, RUBY_DECISION_MODEL_PROVIDER names one if set;
28
+ # otherwise api_key: alone selects OpenRouter, and with no
29
+ # api_key: the environment decides (see Providers.from_env).
25
30
  # api_key: overrides the provider's env var.
26
31
  # model: nil means the provider default; aliases resolve per provider.
27
32
  # base_url: overrides the provider base URL.
33
+ # timeout: read timeout in seconds, and open timeout too when given;
34
+ # nil means the provider's read default (5 for Jev-speed APIs,
35
+ # 30 where the vendor documents multi-second responses) with a
36
+ # 5 second open timeout.
28
37
  # transport: callable(url:, headers:, body:) returning
29
38
  # [status, body_string, headers_hash] (a 2-element return is
30
39
  # still accepted and treated as having no headers).
31
40
  # retry: a RetryPolicy or a Hash of overrides.
32
41
  # random: callable returning a Float in 0...1, used for backoff jitter.
33
42
  # clock: callable returning monotonic seconds, used for total_timeout.
34
- def initialize(provider: nil, api_key: nil, model: nil, base_url: nil, timeout: 5,
43
+ def initialize(provider: nil, api_key: nil, model: nil, base_url: nil, timeout: nil,
35
44
  transport: nil, sleeper: ->(seconds) { sleep(seconds) }, retry: {},
36
45
  random: -> { rand }, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
37
46
  @provider = resolve_provider(provider, api_key: api_key, base_url: base_url)
38
- unless @provider.api_key?
39
- raise ConfigurationError,
40
- "api_key is required for #{@provider.name}: pass api_key: or set #{@provider.env_var}"
41
- end
47
+ @provider.validate!
42
48
 
43
49
  @model = @provider.resolve_model(model)
44
- @timeout = timeout
50
+ @timeout = timeout || @provider.default_timeout
51
+ @open_timeout = timeout || DEFAULT_OPEN_TIMEOUT
45
52
  @transport = transport || default_transport
46
53
  @sleeper = sleeper
47
54
  @retry_policy = RetryPolicy.from(binding.local_variable_get(:retry))
@@ -53,18 +60,46 @@ module RubyDecisionModel
53
60
  @provider.base_url
54
61
  end
55
62
 
56
- def ask(state:, questions:)
63
+ # images: data URLs (see Images) for providers that read images. Each
64
+ # provider places them where its API expects.
65
+ def ask(state:, questions:, images: nil)
57
66
  raise RequestError, "questions must not be empty" if questions.nil? || questions.empty?
58
67
 
59
- body = @provider.request_body(model: @model, state: state, questions: questions)
68
+ body = build_body(state, questions, images)
60
69
  status, response_body, response_headers = perform_with_retry(
61
- url: @provider.url, headers: @provider.headers, body: body
70
+ url: request_url, headers: @provider.headers, body: body
62
71
  )
63
72
  handle_response(status, response_body, response_headers, questions)
64
73
  end
65
74
 
66
75
  private
67
76
 
77
+ # Providers written against 0.1.0 may override url without the model
78
+ # argument added in 0.2.0.
79
+ def request_url
80
+ takes_model = @provider.method(:url).parameters.any? { |type, _| %i[req opt rest].include?(type) }
81
+ takes_model ? @provider.url(@model) : @provider.url
82
+ end
83
+
84
+ def build_body(state, questions, images)
85
+ images = Array(images)
86
+ return @provider.request_body(model: @model, state: state, questions: questions) if images.empty?
87
+
88
+ raise RequestError, "#{@provider.name} does not accept images" unless @provider.supports_images?
89
+
90
+ images.each do |image|
91
+ next if image.is_a?(String) && image.start_with?("data:image/")
92
+
93
+ raise RequestError, "images must be data URLs (data:image/...); see RubyDecisionModel::Images"
94
+ end
95
+
96
+ @provider.request_body(model: @model, state: state, questions: questions, images: images)
97
+ rescue JSON::GeneratorError, JSON::NestingError => e
98
+ # The generator's message can quote the offending value, so it stays
99
+ # on #cause rather than in a message that may be logged.
100
+ raise RequestError, "state or questions could not be encoded as JSON (#{e.class})"
101
+ end
102
+
68
103
  def resolve_provider(provider, api_key:, base_url:)
69
104
  case provider
70
105
  when Providers::Base
@@ -81,7 +116,7 @@ module RubyDecisionModel
81
116
  "no provider configured: pass provider: or api_key:, or set one of #{Providers.env_vars.join(', ')}"
82
117
  )
83
118
  else
84
- Providers.build(:open_router, api_key: api_key, base_url: base_url)
119
+ Providers.build(Providers.named_in_env || :open_router, api_key: api_key, base_url: base_url)
85
120
  end
86
121
  else
87
122
  raise ConfigurationError, "provider must be a Symbol or a Providers::Base, got #{provider.class}"
@@ -93,8 +128,9 @@ module RubyDecisionModel
93
128
  uri = URI.parse(url)
94
129
  http = Net::HTTP.new(uri.host, uri.port)
95
130
  http.use_ssl = uri.scheme == "https"
96
- http.open_timeout = @timeout
131
+ http.open_timeout = @open_timeout
97
132
  http.read_timeout = @timeout
133
+ http.write_timeout = @timeout
98
134
 
99
135
  request = Net::HTTP::Post.new(uri.request_uri)
100
136
  headers.each { |k, v| request[k] = v }
@@ -162,25 +198,23 @@ module RubyDecisionModel
162
198
  raise TransportError.new("transport error: #{exception.message}", cause_error: exception)
163
199
  end
164
200
 
201
+ ERROR_CLASSES = {
202
+ 401 => [Unauthorized, "unauthorized"],
203
+ 413 => [PayloadTooLarge, "payload too large"],
204
+ 422 => [UnprocessableEntity, "unprocessable entity"],
205
+ 429 => [RateLimited, "rate limited"],
206
+ 529 => [Overloaded, "overloaded"]
207
+ }.freeze
208
+ private_constant :ERROR_CLASSES
209
+
165
210
  def handle_response(status, response_body, response_headers, questions)
166
- case status
167
- when 200..299
168
- parse_success(response_body, response_headers, questions)
169
- when 401
170
- raise Unauthorized.new("unauthorized", status: status, body: response_body, headers: response_headers)
171
- when 413
172
- raise PayloadTooLarge.new("payload too large", status: status, body: response_body, headers: response_headers)
173
- when 422
174
- raise UnprocessableEntity.new("unprocessable entity", status: status, body: response_body,
175
- headers: response_headers)
176
- when 429
177
- raise RateLimited.new("rate limited", status: status, body: response_body, headers: response_headers)
178
- when 529
179
- raise Overloaded.new("overloaded", status: status, body: response_body, headers: response_headers)
180
- else
181
- raise ApiError.new("api error (status #{status})", status: status, body: response_body,
182
- headers: response_headers)
183
- end
211
+ return parse_success(response_body, response_headers, questions) if (200..299).cover?(status)
212
+
213
+ klass, message = ERROR_CLASSES.fetch(status) { [ApiError, "api error (status #{status})"] }
214
+ detail = @provider.error_message(response_body)
215
+ message = "#{message}: #{detail}" if detail
216
+
217
+ raise klass.new(message, status: status, body: response_body, headers: response_headers)
184
218
  end
185
219
 
186
220
  def parse_success(response_body, response_headers, questions)
@@ -194,12 +228,16 @@ module RubyDecisionModel
194
228
 
195
229
  raise InvalidResponse, "response body was not a JSON object" unless parsed.is_a?(Hash)
196
230
 
197
- raw_answers = parsed["answers"]
231
+ canonical = @provider.normalize_response(parsed, questions: questions)
232
+ raise InvalidResponse, "response body was not a JSON object" unless canonical.is_a?(Hash)
233
+
234
+ raw_answers = canonical["answers"]
198
235
  raw_answers = {} unless raw_answers.is_a?(Hash)
199
236
 
200
237
  normalized = {}
201
238
  malformed = []
202
239
  missing = []
240
+ refused = []
203
241
 
204
242
  questions.each do |raw_id, question|
205
243
  id = raw_id.to_s
@@ -213,6 +251,7 @@ module RubyDecisionModel
213
251
  malformed << id
214
252
  end
215
253
  else
254
+ refused << id if answer_hash.is_a?(Hash) && answer_hash["type"] == "refusal"
216
255
  missing << id
217
256
  end
218
257
  end
@@ -225,28 +264,27 @@ module RubyDecisionModel
225
264
  end
226
265
 
227
266
  if missing.any?
228
- raise MissingAnswers.new(
229
- "missing or wrong-type answers for: #{missing.join(', ')}",
230
- answers: normalized,
231
- missing: missing
232
- )
267
+ message = "missing or wrong-type answers for: #{missing.join(', ')}"
268
+ message += " (refused: #{refused.join(', ')})" if refused.any?
269
+ raise MissingAnswers.new(message, answers: normalized, missing: missing, refused: refused)
233
270
  end
234
271
 
235
272
  Response.new(
236
273
  answers: normalized,
237
- usage: @provider.usage(parsed),
238
- model: parsed["model"],
239
- id: parsed["id"],
274
+ usage: @provider.usage(canonical),
275
+ model: canonical["model"],
276
+ id: canonical["id"],
240
277
  raw: parsed,
241
278
  request_id: request_id_from(response_headers)
242
279
  )
243
280
  end
244
281
 
245
282
  def request_id_from(headers)
246
- return nil unless headers.is_a?(Hash)
283
+ header = @provider.request_id_header
284
+ return nil unless header && headers.is_a?(Hash)
247
285
 
248
286
  headers.each do |key, value|
249
- next unless key.to_s.casecmp?(REQUEST_ID_HEADER)
287
+ next unless key.to_s.casecmp?(header)
250
288
 
251
289
  return value.is_a?(Array) ? value.first : value
252
290
  end
@@ -259,7 +297,7 @@ module RubyDecisionModel
259
297
  case type
260
298
  when "noul"
261
299
  noul = hash["noul"]
262
- raise MalformedAnswer unless noul.is_a?(Numeric)
300
+ raise MalformedAnswer unless noul.is_a?(Numeric) && noul.between?(0, 1)
263
301
 
264
302
  Answers::Noul.new(noul: noul.to_f, probabilities: hash_or_empty(hash["probabilities"]))
265
303
  when "choice"