ruby_decision_model 0.0.1 → 0.1.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: 2bea0fe1efb5046f07952098626ed15f1dcd28fc80f56fe1810b3a7cd1050bab
4
+ data.tar.gz: ebd51acc65df870cce1cd567033116d262424a2e38f3a86c41403c329a0dea31
5
5
  SHA512:
6
- metadata.gz: dc8ba08eaf6fa210ef58809c7b3e8e585e835dc4bbaa09b7d65956b39d2168cd47d4812dabe7a6c72420fa93ebc0d9f2716e9d33a17d076f0a709ab5a573bd0c
7
- data.tar.gz: 47260e6b68ae8bf8dafec39aae852460281d4423285116f33f8a097da0556e2f0a52327503964310018561e69a3f3cd66fc9b9cb5d51f2dbca6c1bd8c5f9a7c1
6
+ metadata.gz: 5f883f0e2cc526cdbf75b12632359670a70795caa030770c69b9c7af77ebc3cbe2054416f4900e3a5ed3773a507dc53246d1389e04714bc9e45d093b8b7184f8
7
+ data.tar.gz: b4fbb04f181c347fc28ae0c040da2f074d9ada571581cdb2c8d96097e43391d8955ab370dd3f09451ae1fe08a666b68881503ddadddca5979abf633f1b89faef
data/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0 - 2026-09-18
4
+
5
+ Provider-neutral release. One `Client`, two providers behind it.
6
+
7
+ - Added `RubyDecisionModel::Providers` with `Base`, `OpenRouter`, and
8
+ `Typesafe`. A provider owns base URL, endpoint path, auth, default model,
9
+ model aliases, and usage parsing. `Client` delegates to it.
10
+ - `Client.new` takes `provider:` (`:open_router`, `:typesafe`, or a
11
+ `Providers::Base` instance). With no provider and no `api_key:`, the
12
+ environment decides: `TYPESAFE_API_KEY` first, then `OPENROUTER_API_KEY`,
13
+ otherwise `ConfigurationError` naming both. `api_key:` alone still means
14
+ OpenRouter. `model:` defaults to the provider's model. `base_url:` overrides
15
+ the provider base. `client.provider` and `client.model` are readable.
16
+ - Added `RubyDecisionModel.client`, a memoized default client, and
17
+ `RubyDecisionModel.client = nil` to reset it.
18
+ - Model aliases: `jev` and `jev-latest` resolve to `typesafe/jev-1.13` on
19
+ OpenRouter; `typesafe/jev-1.13` and `jev` resolve to `jev-latest` on
20
+ Typesafe. Other names pass through.
21
+ - Both providers send `User-Agent: ruby_decision_model/<version>`.
22
+ - Added `RubyDecisionModel::RetryPolicy` matching the official Typesafe SDKs:
23
+ `max_retries: 2`, exponential backoff from 0.5s capped at 5s with 25%
24
+ jitter, retry on 408, 429, and 5xx, `Retry-After` and `retry-after-ms`
25
+ honored up to 60s, connection errors and timeouts retried, and a
26
+ `total_timeout: 30.0` budget across attempts and delays. `Client` accepts
27
+ `retry:` as a policy or a Hash of overrides. Jitter uses an injectable
28
+ `random:`. Invalid settings raise `ConfigurationError` at construction.
29
+ - Transport contract now returns `[status, body, headers]`. Two-element
30
+ returns are still accepted.
31
+ - Added `Response#request_id` (from `x-typesafe-request-id`, nil on
32
+ OpenRouter) and `Response#nouls`, `#choices`, `#scores`.
33
+ - Added `UnprocessableEntity` (422) and `Overloaded` (529). `ApiError` now
34
+ carries `#headers`.
35
+ - Usage `cost` is `nil` on Typesafe, which does not report it.
36
+ - `Client::DEFAULT_BASE_URL`, `DEFAULT_MODEL`, `MAX_ATTEMPTS`, `RETRYABLE_STATUSES`,
37
+ and `RETRYABLE_EXCEPTIONS` remain as compatibility aliases. They now read from
38
+ the OpenRouter provider and the default `RetryPolicy`, so `MAX_ATTEMPTS` is 3
39
+ and `RETRYABLE_STATUSES` covers 408, 429, and every 5xx.
40
+
3
41
  ## 0.0.1 - 2026-09-18
4
42
 
5
43
  Initial release. Client and question builders for OpenRouter's `/decisions`
data/README.md CHANGED
@@ -1,8 +1,10 @@
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 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.
6
8
 
7
9
  ## Install
8
10
 
@@ -10,12 +12,12 @@ starting with OpenRouter's `/decisions` endpoint and Typesafe Jev.
10
12
  gem "ruby_decision_model"
11
13
  ```
12
14
 
13
- ## Usage
15
+ ## Quick start
14
16
 
15
17
  ```ruby
16
18
  require "ruby_decision_model"
17
19
 
18
- client = RubyDecisionModel::Client.new(api_key: ENV["OPENROUTER_API_KEY"])
20
+ client = RubyDecisionModel::Client.new
19
21
 
20
22
  response = client.ask(
21
23
  state: { title: "Server returns 500 on checkout", reporter: "support" },
@@ -30,20 +32,157 @@ response = client.ask(
30
32
 
31
33
  response["urgent"].noul # => 0.87
32
34
  response["severity"].score # => 2.4
33
- response.usage.cost # => 0.0012
35
+ response.usage.input_tokens # => 120
34
36
  ```
35
37
 
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
+
43
+ ## Providers
44
+
45
+ ### OpenRouter (default)
46
+
47
+ ```ruby
48
+ # ENV["OPENROUTER_API_KEY"]
49
+ client = RubyDecisionModel::Client.new(provider: :open_router)
50
+
51
+ # or pass the key directly; api_key: alone still means OpenRouter
52
+ client = RubyDecisionModel::Client.new(api_key: "sk-or-...")
53
+ ```
54
+
55
+ Requests go to `https://openrouter.ai/api/alpha/decisions`. The default model is
56
+ `typesafe/jev-1.13`. Usage reports `input_tokens`, `output_tokens`, and `cost`.
57
+
58
+ ### Typesafe native API
59
+
60
+ ```ruby
61
+ # ENV["TYPESAFE_API_KEY"]
62
+ client = RubyDecisionModel::Client.new(provider: :typesafe)
63
+ ```
64
+
65
+ Requests go to `https://api.typesafe.ai/v1/systemone`. The default model is
66
+ `jev-latest`. Usage reports `input_tokens` and `output_tokens`; `cost` is `nil`.
67
+ 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.
70
+
71
+ ### Options
72
+
73
+ ```ruby
74
+ RubyDecisionModel::Client.new(
75
+ provider: :typesafe, # :open_router, :typesafe, or a Providers::Base instance
76
+ api_key: nil, # overrides the provider's env var
77
+ model: nil, # nil means the provider default; see aliases below
78
+ base_url: nil, # overrides the provider base URL
79
+ timeout: 5, # open and read timeout in seconds
80
+ retry: { max_retries: 2 }, # RetryPolicy or a Hash of overrides
81
+ transport: nil # see Transport
82
+ )
83
+
84
+ client.provider # => #<RubyDecisionModel::Providers::Typesafe ...>
85
+ client.model # => "jev-latest" (resolved after aliasing)
86
+ ```
87
+
88
+ Both providers send `User-Agent: ruby_decision_model/<version>`.
89
+
90
+ ### Model aliases
91
+
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.
95
+
96
+ | You pass | OpenRouter sends | Typesafe sends |
97
+ | --- | --- | --- |
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` |
102
+ | anything else | as given | as given |
103
+
104
+ ### Writing a provider
105
+
106
+ 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:`.
110
+
111
+ ## Questions and answers
112
+
113
+ Three question types, built with `RubyDecisionModel::Questions`:
114
+
115
+ ```ruby
116
+ Questions.noul("Is this spam?") # yes/no probability
117
+ Questions.choice("Which team?", criteria: { "billing" => "...", "auth" => "..." }) # up to 255 options
118
+ Questions.score("How severe?", criteria: ["cosmetic", "minor", "major"]) # 2 to 10 levels
119
+ ```
120
+
121
+ Answers come back typed: `Answers::Noul` (`noul`, `probabilities`),
122
+ `Answers::Choice` (`choice`, `confidence`, `probabilities`), and
123
+ `Answers::Score` (`score`, `confidence`, `probabilities`, `legend`).
124
+ `response.nouls`, `response.choices`, and `response.scores` return the answers
125
+ of one type keyed the same way as `response.answers`.
126
+
127
+ Score `probabilities` and `legend` are keyed by the wire's string level keys
128
+ (`"0"`, `"1"`, ...), not by the criteria labels. Choice `probabilities` sum to
129
+ approximately 1; treat them as calibrated, not normalized.
130
+
131
+ ## Retries
132
+
133
+ Retry behaviour follows the official Typesafe SDKs and lives in
134
+ `RubyDecisionModel::RetryPolicy`. Pass a policy or a Hash of overrides as
135
+ `retry:`.
136
+
137
+ | Option | Default | Meaning |
138
+ | --- | --- | --- |
139
+ | `max_retries` | `2` | Retries after the initial attempt |
140
+ | `backoff_initial` | `0.5` | First backoff in seconds, doubling each retry |
141
+ | `backoff_max` | `5.0` | Backoff ceiling in seconds |
142
+ | `backoff_jitter` | `0.25` | Fraction of the backoff randomly subtracted |
143
+ | `http_statuses` | `[408, 429] + (500..599)` | Statuses that trigger a retry |
144
+ | `respect_retry_after` | `true` | Honor `Retry-After` and `retry-after-ms` |
145
+ | `max_retry_after` | `60.0` | Ceiling for a server-supplied delay |
146
+ | `retry_connection_errors` | `true` | Retry socket and connection failures |
147
+ | `retry_timeouts` | `true` | Retry open and read timeouts |
148
+ | `total_timeout` | `30.0` | Budget in seconds across attempts and delays; `nil` disables |
149
+
150
+ When the next delay would push past `total_timeout`, the client stops and
151
+ raises the last error instead of sleeping. The budget governs whether another
152
+ attempt starts; an attempt already in flight still runs to its own `timeout`.
153
+
154
+ Invalid settings (a negative duration, a non-integer `max_retries`, a jitter
155
+ outside 0..1, a NaN budget) raise `ConfigurationError` when the client is built.
156
+
157
+ ```ruby
158
+ RubyDecisionModel::Client.new(retry: { max_retries: 4, total_timeout: 60.0 })
159
+ RubyDecisionModel::Client.new(retry: RubyDecisionModel::RetryPolicy.new(max_retries: 0))
160
+ ```
161
+
162
+ ## Transport
163
+
164
+ The client uses `Net::HTTP` by default. Inject `transport:` with any callable
165
+ that accepts `url:`, `headers:`, `body:` and returns
166
+ `[status, body_string, headers_hash]`. A two-element `[status, body_string]`
167
+ return is still accepted and treated as having no headers, which means no
168
+ `Retry-After` support and a nil `request_id`.
169
+
36
170
  ## Errors
37
171
 
38
172
  | Error | Meaning |
39
173
  | --- | --- |
40
- | `ConfigurationError` | Missing api_key, model, or base_url |
174
+ | `ConfigurationError` | No provider could be resolved, missing api_key, unknown provider, or bad `retry:` value |
41
175
  | `RequestError` | Questions hash was empty |
42
- | `TransportError` (`TimeoutError`) | Network or timeout failure |
43
- | `ApiError` (`Unauthorized`, `PayloadTooLarge`, `RateLimited`) | Non-2xx response, carries `#status` and `#body` |
176
+ | `TransportError` (`TimeoutError`) | Network or timeout failure after retries, carries `#cause_error` |
177
+ | `ApiError` | Non-2xx response, carries `#status`, `#body`, and `#headers` |
178
+ | `Unauthorized` | 401 |
179
+ | `PayloadTooLarge` | 413 |
180
+ | `UnprocessableEntity` | 422 (never retried) |
181
+ | `RateLimited` | 429 (retried) |
182
+ | `Overloaded` | 529 (retried) |
44
183
  | `InvalidResponse` | Body wasn't JSON, wasn't a Hash, or an answer was malformed |
45
184
  | `MissingAnswers` | One or more question ids came back missing or wrong-typed, carries `#missing` |
46
185
 
47
- Status: 0.0.1, API may change.
186
+ Status: 0.1.0, API may change.
48
187
 
49
- The companion gem `ruby_dm` builds decisions and verdicts on top of this client.
188
+ The companion gem `decide` builds decisions and verdicts on top of this client.
@@ -2,54 +2,92 @@
2
2
 
3
3
  require "json"
4
4
  require "net/http"
5
+ require "openssl"
5
6
  require "uri"
6
7
 
7
8
  module RubyDecisionModel
8
9
  class Client
9
- DEFAULT_BASE_URL = "https://openrouter.ai/api/alpha"
10
- DEFAULT_MODEL = "typesafe/jev-1.13"
11
- MAX_ATTEMPTS = 2
12
- RETRYABLE_STATUSES = [429, 500, 502, 503, 504, 524, 529].freeze
13
- RETRYABLE_EXCEPTIONS = [
14
- Net::OpenTimeout,
15
- Net::ReadTimeout,
16
- Errno::ECONNRESET,
17
- Errno::ECONNREFUSED,
18
- Errno::EPIPE,
19
- SocketError,
20
- IOError
21
- ].freeze
22
-
23
- def initialize(api_key:, model: DEFAULT_MODEL, base_url: DEFAULT_BASE_URL, timeout: 5,
24
- transport: nil, sleeper: ->(seconds) { sleep(seconds) })
25
- raise ConfigurationError, "api_key is required" if api_key.nil? || api_key.to_s.strip.empty?
26
- raise ConfigurationError, "model is required" if model.nil? || model.to_s.strip.empty?
27
- raise ConfigurationError, "base_url is required" if base_url.nil? || base_url.to_s.strip.empty?
28
-
29
- @api_key = api_key
30
- @model = model
31
- @base_url = base_url.to_s.chomp("/")
10
+ REQUEST_ID_HEADER = "x-typesafe-request-id"
11
+
12
+ # Kept from 0.0.1 for callers that referenced them. They describe the
13
+ # OpenRouter provider and the default RetryPolicy; prefer those directly.
14
+ DEFAULT_BASE_URL = Providers::OpenRouter.new.default_base_url
15
+ DEFAULT_MODEL = Providers::OpenRouter.new.default_model
16
+ MAX_ATTEMPTS = RetryPolicy.new.max_retries + 1
17
+ RETRYABLE_STATUSES = RetryPolicy::DEFAULT_STATUSES
18
+ RETRYABLE_EXCEPTIONS = (RetryPolicy::TIMEOUT_EXCEPTIONS + RetryPolicy::CONNECTION_EXCEPTIONS).freeze
19
+
20
+ attr_reader :provider, :model, :retry_policy, :timeout
21
+
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).
25
+ # api_key: overrides the provider's env var.
26
+ # model: nil means the provider default; aliases resolve per provider.
27
+ # base_url: overrides the provider base URL.
28
+ # transport: callable(url:, headers:, body:) returning
29
+ # [status, body_string, headers_hash] (a 2-element return is
30
+ # still accepted and treated as having no headers).
31
+ # retry: a RetryPolicy or a Hash of overrides.
32
+ # random: callable returning a Float in 0...1, used for backoff jitter.
33
+ # clock: callable returning monotonic seconds, used for total_timeout.
34
+ def initialize(provider: nil, api_key: nil, model: nil, base_url: nil, timeout: 5,
35
+ transport: nil, sleeper: ->(seconds) { sleep(seconds) }, retry: {},
36
+ random: -> { rand }, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
37
+ @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
42
+
43
+ @model = @provider.resolve_model(model)
32
44
  @timeout = timeout
33
45
  @transport = transport || default_transport
34
46
  @sleeper = sleeper
47
+ @retry_policy = RetryPolicy.from(binding.local_variable_get(:retry))
48
+ @random = random
49
+ @clock = clock
50
+ end
51
+
52
+ def base_url
53
+ @provider.base_url
35
54
  end
36
55
 
37
56
  def ask(state:, questions:)
38
57
  raise RequestError, "questions must not be empty" if questions.nil? || questions.empty?
39
58
 
40
- body = JSON.generate({ "model" => @model, "state" => state, "questions" => questions })
41
- headers = {
42
- "Authorization" => "Bearer #{@api_key}",
43
- "Content-Type" => "application/json",
44
- "Accept" => "application/json"
45
- }
46
-
47
- status, response_body = perform_with_retry(url: "#{@base_url}/decisions", headers: headers, body: body)
48
- handle_response(status, response_body, questions)
59
+ body = @provider.request_body(model: @model, state: state, questions: questions)
60
+ status, response_body, response_headers = perform_with_retry(
61
+ url: @provider.url, headers: @provider.headers, body: body
62
+ )
63
+ handle_response(status, response_body, response_headers, questions)
49
64
  end
50
65
 
51
66
  private
52
67
 
68
+ def resolve_provider(provider, api_key:, base_url:)
69
+ case provider
70
+ when Providers::Base
71
+ return provider if api_key.nil? && base_url.nil?
72
+
73
+ # Never mutate a provider the caller may share between clients.
74
+ provider.dup.configure(api_key: api_key, base_url: base_url)
75
+ when Symbol, String
76
+ Providers.build(provider, api_key: api_key, base_url: base_url)
77
+ when nil
78
+ if api_key.nil?
79
+ Providers.from_env&.configure(base_url: base_url) || raise(
80
+ ConfigurationError,
81
+ "no provider configured: pass provider: or api_key:, or set one of #{Providers.env_vars.join(', ')}"
82
+ )
83
+ else
84
+ Providers.build(:open_router, api_key: api_key, base_url: base_url)
85
+ end
86
+ else
87
+ raise ConfigurationError, "provider must be a Symbol or a Providers::Base, got #{provider.class}"
88
+ end
89
+ end
90
+
53
91
  def default_transport
54
92
  lambda do |url:, headers:, body:|
55
93
  uri = URI.parse(url)
@@ -63,64 +101,93 @@ module RubyDecisionModel
63
101
  request.body = body
64
102
 
65
103
  response = http.request(request)
66
- [response.code.to_i, response.body]
104
+ [response.code.to_i, response.body, response.each_header.to_h]
67
105
  end
68
106
  end
69
107
 
70
108
  def perform_with_retry(url:, headers:, body:)
71
- attempts = 0
109
+ policy = @retry_policy
110
+ started_at = @clock.call
111
+ retries = 0
72
112
 
73
113
  loop do
74
- attempts += 1
75
114
  begin
76
- status, response_body = @transport.call(url: url, headers: headers, body: body)
77
- rescue *RETRYABLE_EXCEPTIONS => e
78
- raise_transport_error(e) if attempts >= MAX_ATTEMPTS
79
-
80
- @sleeper.call(backoff_seconds)
81
- next
115
+ status, response_body, response_headers = normalize_transport_result(
116
+ @transport.call(url: url, headers: headers, body: body)
117
+ )
82
118
  rescue Error
83
119
  raise
84
120
  rescue StandardError => e
85
- raise_transport_error(e)
121
+ raise_transport_error(e) unless policy.retryable_exception?(e) && retries < policy.max_retries
122
+
123
+ delay = policy.backoff(retries, random: @random)
124
+ raise_transport_error(e) if budget_exceeded?(policy, started_at, delay)
125
+
126
+ @sleeper.call(delay)
127
+ raise_transport_error(e) if budget_exceeded?(policy, started_at, 0.0)
128
+
129
+ retries += 1
130
+ next
86
131
  end
87
132
 
88
- return [status, response_body] unless RETRYABLE_STATUSES.include?(status) && attempts < MAX_ATTEMPTS
133
+ result = [status, response_body, response_headers]
134
+ return result unless policy.retryable_status?(status) && retries < policy.max_retries
135
+
136
+ delay = policy.delay(retries, headers: response_headers, random: @random)
137
+ return result if budget_exceeded?(policy, started_at, delay)
89
138
 
90
- @sleeper.call(backoff_seconds)
139
+ @sleeper.call(delay)
140
+ return result if budget_exceeded?(policy, started_at, 0.0)
141
+
142
+ retries += 1
91
143
  end
92
144
  end
93
145
 
94
- def backoff_seconds
95
- 0.5 + (rand * 0.25)
146
+ def budget_exceeded?(policy, started_at, delay)
147
+ return false if policy.total_timeout.nil?
148
+
149
+ (@clock.call - started_at) + delay > policy.total_timeout
150
+ end
151
+
152
+ def normalize_transport_result(result)
153
+ status, response_body, response_headers = Array(result)
154
+ [status, response_body, response_headers.is_a?(Hash) ? response_headers : {}]
96
155
  end
97
156
 
98
157
  def raise_transport_error(exception)
99
- if exception.is_a?(Net::OpenTimeout) || exception.is_a?(Net::ReadTimeout)
158
+ if @retry_policy.timeout_exception?(exception)
100
159
  raise TimeoutError.new("request timed out: #{exception.message}", cause_error: exception)
101
160
  end
102
161
 
103
162
  raise TransportError.new("transport error: #{exception.message}", cause_error: exception)
104
163
  end
105
164
 
106
- def handle_response(status, response_body, questions)
165
+ def handle_response(status, response_body, response_headers, questions)
107
166
  case status
108
167
  when 200..299
109
- parse_success(response_body, questions)
168
+ parse_success(response_body, response_headers, questions)
110
169
  when 401
111
- raise Unauthorized.new("unauthorized", status: status, body: response_body)
170
+ raise Unauthorized.new("unauthorized", status: status, body: response_body, headers: response_headers)
112
171
  when 413
113
- raise PayloadTooLarge.new("payload too large", status: status, body: response_body)
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)
114
176
  when 429
115
- raise RateLimited.new("rate limited", status: status, body: response_body)
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)
116
180
  else
117
- raise ApiError.new("api error (status #{status})", status: status, body: response_body)
181
+ raise ApiError.new("api error (status #{status})", status: status, body: response_body,
182
+ headers: response_headers)
118
183
  end
119
184
  end
120
185
 
121
- def parse_success(response_body, questions)
186
+ def parse_success(response_body, response_headers, questions)
187
+ raise InvalidResponse, "response body was empty" if response_body.nil? || response_body.to_s.strip.empty?
188
+
122
189
  parsed = begin
123
- JSON.parse(response_body)
190
+ JSON.parse(response_body.to_s)
124
191
  rescue JSON::ParserError => e
125
192
  raise InvalidResponse, "response body was not valid JSON: #{e.message}"
126
193
  end
@@ -167,13 +234,25 @@ module RubyDecisionModel
167
234
 
168
235
  Response.new(
169
236
  answers: normalized,
170
- usage: normalize_usage(parsed["usage"]),
237
+ usage: @provider.usage(parsed),
171
238
  model: parsed["model"],
172
239
  id: parsed["id"],
173
- raw: parsed
240
+ raw: parsed,
241
+ request_id: request_id_from(response_headers)
174
242
  )
175
243
  end
176
244
 
245
+ def request_id_from(headers)
246
+ return nil unless headers.is_a?(Hash)
247
+
248
+ headers.each do |key, value|
249
+ next unless key.to_s.casecmp?(REQUEST_ID_HEADER)
250
+
251
+ return value.is_a?(Array) ? value.first : value
252
+ end
253
+ nil
254
+ end
255
+
177
256
  class MalformedAnswer < StandardError; end
178
257
 
179
258
  def normalize_answer(type, hash)
@@ -218,15 +297,5 @@ module RubyDecisionModel
218
297
  def hash_or_empty(value)
219
298
  value.is_a?(Hash) ? value : {}
220
299
  end
221
-
222
- def normalize_usage(usage)
223
- usage = {} unless usage.is_a?(Hash)
224
-
225
- Response::Usage.new(
226
- input_tokens: Integer(usage["input_tokens"], exception: false),
227
- output_tokens: Integer(usage["output_tokens"], exception: false),
228
- cost: Float(usage["cost"], exception: false)
229
- )
230
- end
231
300
  end
232
301
  end
@@ -19,12 +19,13 @@ module RubyDecisionModel
19
19
  class TimeoutError < TransportError; end
20
20
 
21
21
  class ApiError < Error
22
- attr_reader :status, :body
22
+ attr_reader :status, :body, :headers
23
23
 
24
- def initialize(message, status:, body:)
24
+ def initialize(message, status:, body:, headers: {})
25
25
  super(message)
26
26
  @status = status
27
27
  @body = body
28
+ @headers = headers || {}
28
29
  end
29
30
  end
30
31
 
@@ -32,8 +33,12 @@ module RubyDecisionModel
32
33
 
33
34
  class PayloadTooLarge < ApiError; end
34
35
 
36
+ class UnprocessableEntity < ApiError; end
37
+
35
38
  class RateLimited < ApiError; end
36
39
 
40
+ class Overloaded < ApiError; end
41
+
37
42
  class InvalidResponse < Error
38
43
  attr_reader :answers
39
44
 
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubyDecisionModel
6
+ module Providers
7
+ # A provider owns everything that differs between decision-model APIs:
8
+ # where requests go, how they are authenticated, which model is the
9
+ # default, which model names are aliases, and how usage is read back.
10
+ # Client keeps the public API and delegates these questions here.
11
+ class Base
12
+ attr_reader :api_key
13
+
14
+ def initialize(api_key: nil, base_url: nil)
15
+ @api_key = api_key || ENV.fetch(env_var, nil)
16
+ @base_url = base_url
17
+ end
18
+
19
+ # Identifier used in error messages and by Client#provider.
20
+ def name
21
+ raise NotImplementedError
22
+ end
23
+
24
+ def env_var
25
+ raise NotImplementedError
26
+ end
27
+
28
+ def default_base_url
29
+ raise NotImplementedError
30
+ end
31
+
32
+ def endpoint_path
33
+ raise NotImplementedError
34
+ end
35
+
36
+ def default_model
37
+ raise NotImplementedError
38
+ end
39
+
40
+ # Map of alias => canonical model name for this provider.
41
+ def aliases
42
+ {}
43
+ end
44
+
45
+ # Whether this provider reports a per-request cost in usage.
46
+ def reports_cost?
47
+ false
48
+ end
49
+
50
+ def base_url
51
+ (@base_url || default_base_url).to_s.chomp("/")
52
+ end
53
+
54
+ def url
55
+ "#{base_url}#{endpoint_path}"
56
+ end
57
+
58
+ def api_key?
59
+ !(api_key.nil? || api_key.to_s.strip.empty?)
60
+ end
61
+
62
+ # Nil or blank means the provider default. Known aliases resolve to the
63
+ # provider's canonical name. Anything else passes through untouched.
64
+ def resolve_model(model)
65
+ return default_model if model.nil? || model.to_s.strip.empty?
66
+
67
+ aliases.fetch(model.to_s, model.to_s)
68
+ end
69
+
70
+ def headers
71
+ {
72
+ "Authorization" => "Bearer #{api_key}",
73
+ "Content-Type" => "application/json",
74
+ "Accept" => "application/json",
75
+ "User-Agent" => "ruby_decision_model/#{VERSION}"
76
+ }
77
+ end
78
+
79
+ def request_body(model:, state:, questions:)
80
+ JSON.generate({ "model" => model, "state" => state, "questions" => questions })
81
+ end
82
+
83
+ def usage(parsed)
84
+ raw = parsed.is_a?(Hash) && parsed["usage"].is_a?(Hash) ? parsed["usage"] : {}
85
+
86
+ Response::Usage.new(
87
+ input_tokens: Integer(raw["input_tokens"], exception: false),
88
+ output_tokens: Integer(raw["output_tokens"], exception: false),
89
+ cost: reports_cost? ? Float(raw["cost"], exception: false) : nil
90
+ )
91
+ end
92
+
93
+ # Keeps the API key out of logs and error output.
94
+ def inspect
95
+ "#<#{self.class.name} name=#{name.inspect} base_url=#{base_url.inspect} api_key=#{api_key? ? '[REDACTED]' : 'nil'}>"
96
+ end
97
+
98
+ # Applies non-nil overrides in place. Used when a caller hands Client a
99
+ # provider instance together with api_key: or base_url:.
100
+ def configure(api_key: nil, base_url: nil)
101
+ @api_key = api_key unless api_key.nil?
102
+ @base_url = base_url unless base_url.nil?
103
+ self
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RubyDecisionModel
6
+ module Providers
7
+ class OpenRouter < Base
8
+ ALIASES = {
9
+ "jev" => "typesafe/jev-1.13",
10
+ "jev-latest" => "typesafe/jev-1.13"
11
+ }.freeze
12
+
13
+ def name
14
+ :open_router
15
+ end
16
+
17
+ def env_var
18
+ "OPENROUTER_API_KEY"
19
+ end
20
+
21
+ def default_base_url
22
+ "https://openrouter.ai/api/alpha"
23
+ end
24
+
25
+ def endpoint_path
26
+ "/decisions"
27
+ end
28
+
29
+ def default_model
30
+ "typesafe/jev-1.13"
31
+ end
32
+
33
+ def aliases
34
+ ALIASES
35
+ end
36
+
37
+ def reports_cost?
38
+ true
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RubyDecisionModel
6
+ module Providers
7
+ class Typesafe < Base
8
+ ALIASES = {
9
+ "typesafe/jev-1.13" => "jev-latest",
10
+ "jev" => "jev-latest"
11
+ }.freeze
12
+
13
+ def name
14
+ :typesafe
15
+ end
16
+
17
+ def env_var
18
+ "TYPESAFE_API_KEY"
19
+ end
20
+
21
+ def default_base_url
22
+ "https://api.typesafe.ai"
23
+ end
24
+
25
+ def endpoint_path
26
+ "/v1/systemone"
27
+ end
28
+
29
+ def default_model
30
+ "jev-latest"
31
+ end
32
+
33
+ def aliases
34
+ ALIASES
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "providers/base"
4
+ require_relative "providers/open_router"
5
+ require_relative "providers/typesafe"
6
+
7
+ module RubyDecisionModel
8
+ module Providers
9
+ REGISTRY = {
10
+ open_router: OpenRouter,
11
+ typesafe: Typesafe
12
+ }.freeze
13
+
14
+ module_function
15
+
16
+ def names
17
+ REGISTRY.keys
18
+ end
19
+
20
+ def build(name, api_key: nil, base_url: nil)
21
+ klass = REGISTRY[name.to_s.to_sym]
22
+ raise ConfigurationError, "unknown provider #{name.inspect}; known providers: #{names.join(', ')}" if klass.nil?
23
+
24
+ klass.new(api_key: api_key, base_url: base_url)
25
+ end
26
+
27
+ # Order in which environment variables are consulted when no provider or
28
+ # api_key is given. Typesafe wins when both keys are set.
29
+ ENV_PRIORITY = [Typesafe, OpenRouter].freeze
30
+
31
+ # Picks a provider from the environment, or nil when no key is set.
32
+ def from_env
33
+ ENV_PRIORITY.each do |klass|
34
+ provider = klass.new
35
+ return provider if provider.api_key?
36
+ end
37
+ nil
38
+ end
39
+
40
+ def env_vars
41
+ ENV_PRIORITY.map { |klass| klass.new.env_var }
42
+ end
43
+ end
44
+ end
@@ -4,18 +4,38 @@ module RubyDecisionModel
4
4
  class Response
5
5
  Usage = Data.define(:input_tokens, :output_tokens, :cost)
6
6
 
7
- attr_reader :answers, :usage, :model, :id, :raw
7
+ attr_reader :answers, :usage, :model, :id, :raw, :request_id
8
8
 
9
- def initialize(answers:, usage:, model:, id:, raw:)
9
+ def initialize(answers:, usage:, model:, id:, raw:, request_id: nil)
10
10
  @answers = answers
11
11
  @usage = usage
12
12
  @model = model
13
13
  @id = id
14
14
  @raw = raw
15
+ @request_id = request_id
15
16
  end
16
17
 
17
18
  def [](id)
18
19
  answers[id]
19
20
  end
21
+
22
+ # Answers filtered by type, keyed the same way as #answers.
23
+ def nouls
24
+ answers_of_type("noul")
25
+ end
26
+
27
+ def choices
28
+ answers_of_type("choice")
29
+ end
30
+
31
+ def scores
32
+ answers_of_type("score")
33
+ end
34
+
35
+ private
36
+
37
+ def answers_of_type(type)
38
+ answers.select { |_id, answer| answer.type == type }
39
+ end
20
40
  end
21
41
  end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module RubyDecisionModel
6
+ # Retry rules shared by every provider. Defaults follow the official
7
+ # Typesafe SDKs: two retries after the initial attempt, exponential
8
+ # backoff from 0.5s capped at 5s with up to 25% jitter subtracted,
9
+ # Retry-After honored up to 60s, and a 30s total budget across attempts.
10
+ class RetryPolicy < Data.define(
11
+ :max_retries,
12
+ :backoff_initial,
13
+ :backoff_max,
14
+ :backoff_jitter,
15
+ :http_statuses,
16
+ :respect_retry_after,
17
+ :max_retry_after,
18
+ :retry_connection_errors,
19
+ :retry_timeouts,
20
+ :total_timeout
21
+ )
22
+ DEFAULT_STATUSES = ([408, 429] + (500..599).to_a).freeze
23
+
24
+ TIMEOUT_EXCEPTIONS = [Net::OpenTimeout, Net::ReadTimeout].freeze
25
+ CONNECTION_EXCEPTIONS = [
26
+ Errno::ECONNRESET,
27
+ Errno::ECONNREFUSED,
28
+ Errno::ECONNABORTED,
29
+ Errno::EHOSTUNREACH,
30
+ Errno::ENETUNREACH,
31
+ Errno::EPIPE,
32
+ SocketError,
33
+ IOError,
34
+ OpenSSL::SSL::SSLError
35
+ ].freeze
36
+
37
+ def initialize(max_retries: 2, backoff_initial: 0.5, backoff_max: 5.0, backoff_jitter: 0.25,
38
+ http_statuses: DEFAULT_STATUSES, respect_retry_after: true, max_retry_after: 60.0,
39
+ retry_connection_errors: true, retry_timeouts: true, total_timeout: 30.0)
40
+ super
41
+ validate!
42
+ end
43
+
44
+ # Accepts a RetryPolicy, a Hash of overrides, or nil (defaults).
45
+ def self.from(value)
46
+ case value
47
+ when RetryPolicy then value
48
+ when nil then new
49
+ when Hash then new(**value.transform_keys(&:to_sym))
50
+ else
51
+ raise ConfigurationError, "retry must be a RetryPolicy or a Hash of overrides, got #{value.class}"
52
+ end
53
+ end
54
+
55
+ def retryable_status?(status)
56
+ http_statuses.include?(status)
57
+ end
58
+
59
+ def timeout_exception?(exception)
60
+ TIMEOUT_EXCEPTIONS.any? { |klass| exception.is_a?(klass) }
61
+ end
62
+
63
+ def connection_exception?(exception)
64
+ CONNECTION_EXCEPTIONS.any? { |klass| exception.is_a?(klass) }
65
+ end
66
+
67
+ def retryable_exception?(exception)
68
+ return retry_timeouts if timeout_exception?(exception)
69
+ return retry_connection_errors if connection_exception?(exception)
70
+
71
+ false
72
+ end
73
+
74
+ # Seconds to wait before the retry numbered `retry_number` (0 for the
75
+ # first retry). `random` returns a Float in 0...1 and exists so tests can
76
+ # pin the jitter.
77
+ def backoff(retry_number, random: -> { rand })
78
+ base = [backoff_initial * (2**retry_number), backoff_max].min
79
+ [base * (1.0 - (backoff_jitter * random.call)), 0.0].max
80
+ end
81
+
82
+ # Delay before the next retry: the server's Retry-After when present and
83
+ # honored (clamped to max_retry_after), otherwise the computed backoff.
84
+ def delay(retry_number, headers: {}, random: -> { rand })
85
+ hinted = respect_retry_after ? retry_after_seconds(headers) : nil
86
+ return [hinted, max_retry_after].min if hinted
87
+
88
+ backoff(retry_number, random: random)
89
+ end
90
+
91
+ # Reads retry-after-ms (preferred) or Retry-After (seconds or HTTP date).
92
+ # Header names are matched case-insensitively. Returns nil when absent
93
+ # or unparseable.
94
+ def retry_after_seconds(headers)
95
+ return nil unless headers.is_a?(Hash)
96
+
97
+ ms = header_value(headers, "retry-after-ms")
98
+ if ms
99
+ parsed = Float(ms, exception: false)
100
+ return parsed / 1000.0 if parsed && parsed >= 0
101
+ end
102
+
103
+ raw = header_value(headers, "retry-after")
104
+ return nil if raw.nil?
105
+
106
+ seconds = Float(raw, exception: false)
107
+ return seconds if seconds && seconds >= 0
108
+
109
+ begin
110
+ [Time.httpdate(raw.to_s) - Time.now, 0.0].max
111
+ rescue ArgumentError
112
+ nil
113
+ end
114
+ end
115
+
116
+ private
117
+
118
+ def validate!
119
+ unless max_retries.is_a?(Integer) && max_retries >= 0
120
+ raise ConfigurationError, "max_retries must be a non-negative Integer, got #{max_retries.inspect}"
121
+ end
122
+
123
+ { backoff_initial: backoff_initial, backoff_max: backoff_max, max_retry_after: max_retry_after }.each do |name, value|
124
+ next if finite_non_negative?(value)
125
+
126
+ raise ConfigurationError, "#{name} must be a finite non-negative number, got #{value.inspect}"
127
+ end
128
+
129
+ unless finite_non_negative?(backoff_jitter) && backoff_jitter <= 1.0
130
+ raise ConfigurationError, "backoff_jitter must be a number between 0 and 1, got #{backoff_jitter.inspect}"
131
+ end
132
+
133
+ unless total_timeout.nil? || finite_non_negative?(total_timeout)
134
+ raise ConfigurationError, "total_timeout must be nil or a finite non-negative number, got #{total_timeout.inspect}"
135
+ end
136
+
137
+ return if http_statuses.respond_to?(:include?)
138
+
139
+ raise ConfigurationError, "http_statuses must respond to include?, got #{http_statuses.inspect}"
140
+ end
141
+
142
+ def finite_non_negative?(value)
143
+ value.is_a?(Numeric) && value.finite? && value >= 0
144
+ end
145
+
146
+ def header_value(headers, name)
147
+ headers.each do |key, value|
148
+ next unless key.to_s.casecmp?(name)
149
+
150
+ return value.is_a?(Array) ? value.first : value
151
+ end
152
+ nil
153
+ end
154
+ end
155
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyDecisionModel
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
5
5
  end
@@ -1,11 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "net/http"
4
+ require "openssl"
5
+
3
6
  require_relative "ruby_decision_model/version"
4
7
  require_relative "ruby_decision_model/errors"
5
8
  require_relative "ruby_decision_model/questions"
6
9
  require_relative "ruby_decision_model/answers"
7
10
  require_relative "ruby_decision_model/response"
11
+ require_relative "ruby_decision_model/providers"
12
+ require_relative "ruby_decision_model/retry_policy"
8
13
  require_relative "ruby_decision_model/client"
9
14
 
10
15
  module RubyDecisionModel
16
+ class << self
17
+ # A memoized default client built from the environment. Reset with
18
+ # `RubyDecisionModel.client = nil`.
19
+ def client
20
+ @client ||= Client.new
21
+ end
22
+
23
+ attr_writer :client
24
+ end
11
25
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_decision_model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Obie Fernandez
@@ -40,9 +40,9 @@ dependencies:
40
40
  version: '13.0'
41
41
  description: Decision models answer typed questions about a state instead of generating
42
42
  text. ruby_decision_model builds noul (yes/no probability), choice, and score questions,
43
- posts them with a state to a decisions endpoint (OpenRouter's /decisions with Typesafe
44
- Jev to start), and returns normalized answers with probabilities, confidence, legends,
45
- and usage.
43
+ posts them with a state to a decision-model provider (OpenRouter by default, Typesafe's
44
+ native API as a second door), and returns normalized answers with probabilities,
45
+ confidence, legends, and usage. Retries follow the official Typesafe SDKs.
46
46
  email:
47
47
  - obiefernandez@gmail.com
48
48
  executables: []
@@ -56,8 +56,13 @@ files:
56
56
  - lib/ruby_decision_model/answers.rb
57
57
  - lib/ruby_decision_model/client.rb
58
58
  - lib/ruby_decision_model/errors.rb
59
+ - lib/ruby_decision_model/providers.rb
60
+ - lib/ruby_decision_model/providers/base.rb
61
+ - lib/ruby_decision_model/providers/open_router.rb
62
+ - lib/ruby_decision_model/providers/typesafe.rb
59
63
  - lib/ruby_decision_model/questions.rb
60
64
  - lib/ruby_decision_model/response.rb
65
+ - lib/ruby_decision_model/retry_policy.rb
61
66
  - lib/ruby_decision_model/version.rb
62
67
  homepage: https://github.com/obie/ruby_decision_model
63
68
  licenses:
@@ -83,5 +88,6 @@ requirements: []
83
88
  rubygems_version: 3.5.11
84
89
  signing_key:
85
90
  specification_version: 4
86
- summary: Ruby client for decision models such as Typesafe Jev
91
+ summary: 'The decision-model interface for Ruby: OpenRouter and Typesafe behind one
92
+ client'
87
93
  test_files: []