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 +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +217 -32
- data/lib/ruby_decision_model/client.rb +86 -48
- data/lib/ruby_decision_model/errors.rb +6 -2
- data/lib/ruby_decision_model/images.rb +33 -0
- data/lib/ruby_decision_model/providers/base.rb +155 -10
- data/lib/ruby_decision_model/providers/cloudflare.rb +119 -0
- data/lib/ruby_decision_model/providers/databricks.rb +87 -0
- data/lib/ruby_decision_model/providers/open_router.rb +9 -1
- data/lib/ruby_decision_model/providers/openai.rb +237 -0
- data/lib/ruby_decision_model/providers/perplexity.rb +73 -0
- data/lib/ruby_decision_model/providers/system_one.rb +90 -0
- data/lib/ruby_decision_model/providers/typesafe.rb +1 -0
- data/lib/ruby_decision_model/providers.rb +41 -7
- data/lib/ruby_decision_model/retry_policy.rb +1 -1
- data/lib/ruby_decision_model/version.rb +1 -1
- data/lib/ruby_decision_model.rb +1 -0
- metadata +15 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f1c6972a68e2a0545795663a4c792603a692eb65b609d630ce2754e9f032d6b2
|
|
4
|
+
data.tar.gz: 4252b768aa5f7ab6e16eee20ef09ca16923fdf2b192eb21a0fb79f06f51f8b42
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
6
|
-
default
|
|
7
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
```
|
|
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
|
|
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
|
|
69
|
-
|
|
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, #
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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 |
|
|
220
|
+
| You pass | OpenRouter sends | Native provider sends |
|
|
97
221
|
| --- | --- | --- |
|
|
98
|
-
| `nil` | `typesafe/jev-1.13` |
|
|
99
|
-
| `"jev"` | `typesafe/jev-1.13` | `jev-latest` |
|
|
100
|
-
| `"jev-
|
|
101
|
-
| `"
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
#
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
68
|
+
body = build_body(state, questions, images)
|
|
60
69
|
status, response_body, response_headers = perform_with_retry(
|
|
61
|
-
url:
|
|
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 = @
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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(
|
|
238
|
-
model:
|
|
239
|
-
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
|
-
|
|
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?(
|
|
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"
|