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.
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RubyDecisionModel
6
+ module Providers
7
+ # OpenAI's Decisions API (public beta, gpt-6-luna). Its wire format
8
+ # differs from System One in every direction, so this provider
9
+ # translates both ways:
10
+ #
11
+ # - state becomes `input`. Strings pass through; anything else is sent
12
+ # as JSON text, since the API has no structured input. Images become
13
+ # input_image parts on a single user message.
14
+ # - questions become a named array. noul is `predicate`; a noul with
15
+ # true/false criteria becomes a boolean `choice` so the descriptions
16
+ # reach the model, and its answer is read back as a noul. choice
17
+ # criteria become `choices`, score criteria become `levels`.
18
+ # - answers come back as an array of predicate/choice/score/refusal
19
+ # objects with probability arrays, and are rebuilt keyed by id with
20
+ # probability Hashes and a score legend.
21
+ class OpenAI < Base
22
+ ALIASES = {
23
+ "luna" => "gpt-6-luna",
24
+ "openai/gpt-6-luna" => "gpt-6-luna",
25
+ "openai/gpt-6-luna-decisions" => "gpt-6-luna",
26
+ "gpt-6-luna-decisions" => "gpt-6-luna"
27
+ }.freeze
28
+
29
+ def name
30
+ :openai
31
+ end
32
+
33
+ def env_var
34
+ "OPENAI_API_KEY"
35
+ end
36
+
37
+ def default_base_url
38
+ "https://api.openai.com/v1"
39
+ end
40
+
41
+ def endpoint_path
42
+ "/decisions"
43
+ end
44
+
45
+ def default_model
46
+ "gpt-6-luna"
47
+ end
48
+
49
+ def aliases
50
+ ALIASES
51
+ end
52
+
53
+ # Inputs run to a million tokens, and OpenAI publishes no latency
54
+ # figures for this endpoint yet.
55
+ def default_timeout
56
+ 30
57
+ end
58
+
59
+ def supports_images?
60
+ true
61
+ end
62
+
63
+ def request_id_header
64
+ "x-request-id"
65
+ end
66
+
67
+ def request_body(model:, state:, questions:, images: nil)
68
+ JSON.generate(
69
+ "model" => model,
70
+ "input" => input(state, images),
71
+ "questions" => questions.map { |id, question| encode_question(id.to_s, question) }
72
+ )
73
+ end
74
+
75
+ # Answers are matched by name. An unnamed answer falls back to its
76
+ # position, since the API answers in question order, but never onto an
77
+ # id some other answer names. An id answered twice is ambiguous, so it
78
+ # reads as malformed rather than letting one answer (or a refusal)
79
+ # silently replace another.
80
+ def normalize_response(parsed, questions:)
81
+ by_id = questions.to_h { |id, question| [id.to_s, question] }
82
+ ids = by_id.keys
83
+ entries = Array(parsed["answers"])
84
+ named = entries.filter_map { |answer| answer["name"] if answer.is_a?(Hash) && answer["name"].is_a?(String) }
85
+ answers = {}
86
+
87
+ entries.each_with_index do |answer, index|
88
+ next unless answer.is_a?(Hash)
89
+
90
+ id = answer["name"].is_a?(String) ? answer["name"] : ids[index]
91
+ next if id.nil? || (!answer["name"].is_a?(String) && named.include?(id))
92
+
93
+ answers[id] = answers.key?(id) ? ambiguous(by_id[id]) : decode_answer(answer, by_id[id])
94
+ end
95
+
96
+ { "id" => parsed["id"], "model" => parsed["model"], "answers" => answers, "usage" => parsed["usage"] }
97
+ end
98
+
99
+ private
100
+
101
+ def input(state, images)
102
+ text = state_text(state)
103
+ return text if images.nil? || images.empty?
104
+
105
+ content = []
106
+ content << { "type" => "input_text", "text" => text } unless text.empty?
107
+ images.each { |url| content << { "type" => "input_image", "image_url" => url } }
108
+ [{ "role" => "user", "content" => content }]
109
+ end
110
+
111
+ def state_text(state)
112
+ case state
113
+ when String then state
114
+ when nil then ""
115
+ else JSON.generate(state)
116
+ end
117
+ end
118
+
119
+ def encode_question(id, question)
120
+ question = stringify(question)
121
+ instructions = question["instructions"]
122
+ raise RequestError, "question #{id} needs instructions for openai" if instructions.nil?
123
+
124
+ instructions = JSON.generate(instructions) unless instructions.is_a?(String)
125
+ criteria = question["criteria"]
126
+
127
+ case question["type"]
128
+ when "noul"
129
+ return { "name" => id, "type" => "predicate", "instructions" => instructions } unless criteria.is_a?(Hash)
130
+
131
+ descriptions = stringify(criteria)
132
+ { "name" => id, "type" => "choice", "instructions" => instructions,
133
+ "choices" => [true, false].map { |value| described({ "value" => value }, descriptions[value.to_s]) } }
134
+ when "choice"
135
+ { "name" => id, "type" => "choice", "instructions" => instructions,
136
+ "choices" => stringify(criteria).map { |value, text| described({ "value" => value }, text) } }
137
+ when "score"
138
+ { "name" => id, "type" => "score", "instructions" => instructions,
139
+ "levels" => Array(criteria).map { |level| encode_level(level) } }
140
+ else
141
+ question.merge("name" => id)
142
+ end
143
+ end
144
+
145
+ # Jev score criteria are descriptions: usually strings, sometimes
146
+ # objects. A Hash with a label keeps its label and description;
147
+ # anything else becomes the label as text.
148
+ def encode_level(level)
149
+ level = stringify(level) if level.is_a?(Hash)
150
+ return described({ "label" => level["label"].to_s }, level["description"]) if level.is_a?(Hash) && level.key?("label")
151
+ return { "label" => level } if level.is_a?(String)
152
+
153
+ { "label" => JSON.generate(level) }
154
+ end
155
+
156
+ def described(hash, description)
157
+ return hash if description.nil?
158
+
159
+ hash.merge("description" => description.is_a?(String) ? description : JSON.generate(description))
160
+ end
161
+
162
+ def decode_answer(answer, question)
163
+ question = stringify(question)
164
+
165
+ case answer["type"]
166
+ when "predicate"
167
+ { "type" => "noul", "noul" => answer["probability"] }
168
+ when "choice"
169
+ question["type"] == "noul" ? decode_boolean_choice(answer) : decode_choice(answer)
170
+ when "score"
171
+ decode_score(answer, Array(question["criteria"]))
172
+ when "refusal"
173
+ { "type" => "refusal" }
174
+ else
175
+ answer
176
+ end.then { |decoded| noul_probabilities(decoded) }
177
+ end
178
+
179
+ # Carries the expected type with none of its fields, so Client reports
180
+ # the id as malformed.
181
+ def ambiguous(question)
182
+ { "type" => stringify(question)["type"] }
183
+ end
184
+
185
+ def decode_boolean_choice(answer)
186
+ return { "type" => "noul" } if duplicate_values?(answer)
187
+
188
+ entry = probability_entries(answer).find { |item| item["value"] == true }
189
+ { "type" => "noul", "noul" => entry && entry["probability"] }
190
+ end
191
+
192
+ def decode_choice(answer)
193
+ return { "type" => "choice" } if duplicate_values?(answer)
194
+
195
+ probabilities = probability_entries(answer).to_h { |item| [item["value"].to_s, item["probability"]] }
196
+ choice = answer["choice"]
197
+ choice = choice.to_s if [true, false].include?(choice)
198
+
199
+ { "type" => "choice", "choice" => choice, "confidence" => answer["confidence"], "probabilities" => probabilities }
200
+ end
201
+
202
+ # The legend maps each level index to the criteria entry the caller
203
+ # wrote, as Jev does, falling back to the label OpenAI echoes.
204
+ def decode_score(answer, criteria)
205
+ return { "type" => "score" } if duplicate_values?(answer)
206
+
207
+ entries = probability_entries(answer)
208
+ legend = entries.to_h do |item|
209
+ index = item["value"]
210
+ [index.to_s, index.is_a?(Integer) && index.between?(0, criteria.size - 1) ? criteria[index] : item["label"]]
211
+ end
212
+
213
+ { "type" => "score", "score" => answer["score"], "confidence" => answer["confidence"],
214
+ "probabilities" => entries.to_h { |item| [item["value"].to_s, item["probability"]] },
215
+ "legend" => legend }
216
+ end
217
+
218
+ def probability_entries(answer)
219
+ Array(answer["probabilities"]).select { |item| item.is_a?(Hash) && item.key?("value") }
220
+ end
221
+
222
+ # Two entries for one value make the split ambiguous. The answer then
223
+ # carries no fields and Client reports it as malformed. Values compare
224
+ # as the string keys the decoded probabilities use, so true and "true"
225
+ # (or 1 and "1") count as the same value rather than one silently
226
+ # replacing the other.
227
+ def duplicate_values?(answer)
228
+ values = probability_entries(answer).map { |item| item["value"].to_s }
229
+ values.uniq.size != values.size
230
+ end
231
+
232
+ def stringify(hash)
233
+ hash.is_a?(Hash) ? hash.transform_keys(&:to_s) : {}
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RubyDecisionModel
6
+ module Providers
7
+ # Perplexity's Decisions API serving pplx-decider. The body is System One.
8
+ # Images ride inside state as OpenAI-style image_url parts, so state
9
+ # becomes an array when images are present.
10
+ class Perplexity < Base
11
+ ALIASES = {
12
+ "pplx-decider" => "pplx-decider-v1.1-27b",
13
+ "perplexity/pplx-decider-v1-27b" => "pplx-decider-v1-27b"
14
+ }.freeze
15
+
16
+ def name
17
+ :perplexity
18
+ end
19
+
20
+ def env_var
21
+ "PERPLEXITY_API_KEY"
22
+ end
23
+
24
+ def default_base_url
25
+ "https://api.perplexity.ai"
26
+ end
27
+
28
+ def endpoint_path
29
+ "/v1/decisions"
30
+ end
31
+
32
+ def default_model
33
+ "pplx-decider-v1.1-27b"
34
+ end
35
+
36
+ def aliases
37
+ ALIASES
38
+ end
39
+
40
+ # Perplexity documents 5 to 23 seconds for large inputs and uses 30 in
41
+ # its own examples.
42
+ def default_timeout
43
+ 30
44
+ end
45
+
46
+ def supports_images?
47
+ true
48
+ end
49
+
50
+ def request_id_header
51
+ "x-request-id"
52
+ end
53
+
54
+ def request_body(model:, state:, questions:, images: nil)
55
+ return super(model: model, state: state, questions: questions) if images.nil? || images.empty?
56
+
57
+ parts = images.map { |url| { "type" => "image_url", "image_url" => { "url" => url } } }
58
+ super(model: model, state: state_parts(state) + parts, questions: questions)
59
+ end
60
+
61
+ private
62
+
63
+ def state_parts(state)
64
+ case state
65
+ when nil then []
66
+ when String then state.empty? ? [] : [state]
67
+ when Array then state
68
+ else [JSON.generate(state)]
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "base"
5
+
6
+ module RubyDecisionModel
7
+ module Providers
8
+ # Any server that speaks Typesafe's System One API at /v1/systemone:
9
+ # Ollama, the autojev server that ships with pplx-decider's weights,
10
+ # strands-decider's local server, and hosted lookalikes. The base URL is
11
+ # required and has no /v1 suffix. The API key and model are optional,
12
+ # since local servers often run without auth and some pick their own
13
+ # model. SYSTEM_ONE_BASE_URL and SYSTEM_ONE_API_KEY follow Pydantic AI.
14
+ class SystemOne < Base
15
+ BASE_URL_ENV_VAR = "SYSTEM_ONE_BASE_URL"
16
+
17
+ def initialize(api_key: nil, base_url: nil)
18
+ super
19
+ @base_url ||= ENV.fetch(BASE_URL_ENV_VAR, nil)
20
+ end
21
+
22
+ def name
23
+ :system_one
24
+ end
25
+
26
+ def env_var
27
+ "SYSTEM_ONE_API_KEY"
28
+ end
29
+
30
+ def default_base_url
31
+ nil
32
+ end
33
+
34
+ def endpoint_path
35
+ "/v1/systemone"
36
+ end
37
+
38
+ def default_model
39
+ nil
40
+ end
41
+
42
+ def requires_api_key?
43
+ false
44
+ end
45
+
46
+ # A local server may load the model on the first request.
47
+ def default_timeout
48
+ 30
49
+ end
50
+
51
+ # Sent as the System One images extension; servers without image
52
+ # support reject the request.
53
+ def supports_images?
54
+ true
55
+ end
56
+
57
+ def validate!
58
+ super
59
+ return unless base_url.empty?
60
+
61
+ raise ConfigurationError, "base_url is required for system_one: pass base_url: or set #{BASE_URL_ENV_VAR}"
62
+ end
63
+
64
+ # Checked when a request is built rather than in validate!, so a
65
+ # client's own base_url: can still replace an unusable
66
+ # SYSTEM_ONE_BASE_URL. Ollama hosts are often written as
67
+ # localhost:11434, and whether the server speaks http or https is not
68
+ # ours to guess; an empty host would quietly mean this machine.
69
+ def url(model = nil)
70
+ uri = URI.parse(base_url)
71
+ unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
72
+ raise ConfigurationError, "system_one base_url needs http:// or https:// and a host, got #{base_url.inspect}"
73
+ end
74
+
75
+ super
76
+ rescue URI::InvalidURIError
77
+ raise ConfigurationError, "system_one base_url is not a URL: #{base_url.inspect}"
78
+ end
79
+
80
+ def request_body(model:, state:, questions:, images: nil)
81
+ body = {}
82
+ body["model"] = model unless model.nil?
83
+ body["state"] = state
84
+ body["questions"] = questions
85
+ body["images"] = images if images
86
+ JSON.generate(body)
87
+ end
88
+ end
89
+ end
90
+ end
@@ -7,6 +7,7 @@ module RubyDecisionModel
7
7
  class Typesafe < Base
8
8
  ALIASES = {
9
9
  "typesafe/jev-1.13" => "jev-latest",
10
+ "~typesafe/jev-latest" => "jev-latest",
10
11
  "jev" => "jev-latest"
11
12
  }.freeze
12
13
 
@@ -3,42 +3,76 @@
3
3
  require_relative "providers/base"
4
4
  require_relative "providers/open_router"
5
5
  require_relative "providers/typesafe"
6
+ require_relative "providers/openai"
7
+ require_relative "providers/cloudflare"
8
+ require_relative "providers/perplexity"
9
+ require_relative "providers/databricks"
10
+ require_relative "providers/system_one"
6
11
 
7
12
  module RubyDecisionModel
8
13
  module Providers
9
14
  REGISTRY = {
10
15
  open_router: OpenRouter,
11
- typesafe: Typesafe
16
+ typesafe: Typesafe,
17
+ openai: OpenAI,
18
+ cloudflare: Cloudflare,
19
+ perplexity: Perplexity,
20
+ databricks: Databricks,
21
+ system_one: SystemOne
12
22
  }.freeze
13
23
 
24
+ # Names a provider from the environment, ahead of any key sniffing.
25
+ PROVIDER_ENV_VAR = "RUBY_DECISION_MODEL_PROVIDER"
26
+
14
27
  module_function
15
28
 
16
29
  def names
17
30
  REGISTRY.keys
18
31
  end
19
32
 
33
+ # Names are matched without regard to case, and `-` reads as `_`, so
34
+ # "OpenAI" and "open-router" from an env file both resolve.
20
35
  def build(name, api_key: nil, base_url: nil)
21
- klass = REGISTRY[name.to_s.to_sym]
36
+ klass = REGISTRY[name.to_s.strip.downcase.tr("-", "_").to_sym]
22
37
  raise ConfigurationError, "unknown provider #{name.inspect}; known providers: #{names.join(', ')}" if klass.nil?
23
38
 
24
39
  klass.new(api_key: api_key, base_url: base_url)
25
40
  end
26
41
 
27
42
  # 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
43
+ # api_key is given. Typesafe wins when several are set. Only settings
44
+ # that exist for decision models take part; general-purpose credentials
45
+ # such as OPENAI_API_KEY or CLOUDFLARE_API_TOKEN never pick a provider
46
+ # on their own. Name those with RUBY_DECISION_MODEL_PROVIDER.
47
+ ENV_PRIORITY = [Typesafe, OpenRouter, SystemOne].freeze
48
+
49
+ # The provider name RUBY_DECISION_MODEL_PROVIDER holds, or nil.
50
+ def named_in_env
51
+ named = ENV.fetch(PROVIDER_ENV_VAR, nil).to_s.strip
52
+ named.empty? ? nil : named
53
+ end
30
54
 
31
- # Picks a provider from the environment, or nil when no key is set.
55
+ # Picks a provider from the environment, or nil when nothing is set.
56
+ # RUBY_DECISION_MODEL_PROVIDER wins when present, even if that provider
57
+ # turns out to be missing its key, so the error names the right thing.
32
58
  def from_env
59
+ named = named_in_env
60
+ return build(named) if named
61
+
33
62
  ENV_PRIORITY.each do |klass|
34
63
  provider = klass.new
35
- return provider if provider.api_key?
64
+ return provider if provider.configured?
36
65
  end
37
66
  nil
38
67
  end
39
68
 
40
69
  def env_vars
41
- ENV_PRIORITY.map { |klass| klass.new.env_var }
70
+ [PROVIDER_ENV_VAR] + ENV_PRIORITY.map { |klass| env_var_for(klass) }
71
+ end
72
+
73
+ def env_var_for(klass)
74
+ klass == SystemOne ? SystemOne::BASE_URL_ENV_VAR : klass.new.env_var
42
75
  end
76
+ private_class_method :env_var_for
43
77
  end
44
78
  end
@@ -21,7 +21,7 @@ module RubyDecisionModel
21
21
  )
22
22
  DEFAULT_STATUSES = ([408, 429] + (500..599).to_a).freeze
23
23
 
24
- TIMEOUT_EXCEPTIONS = [Net::OpenTimeout, Net::ReadTimeout].freeze
24
+ TIMEOUT_EXCEPTIONS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout].freeze
25
25
  CONNECTION_EXCEPTIONS = [
26
26
  Errno::ECONNRESET,
27
27
  Errno::ECONNREFUSED,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyDecisionModel
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -6,6 +6,7 @@ require "openssl"
6
6
  require_relative "ruby_decision_model/version"
7
7
  require_relative "ruby_decision_model/errors"
8
8
  require_relative "ruby_decision_model/questions"
9
+ require_relative "ruby_decision_model/images"
9
10
  require_relative "ruby_decision_model/answers"
10
11
  require_relative "ruby_decision_model/response"
11
12
  require_relative "ruby_decision_model/providers"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_decision_model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Obie Fernandez
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-18 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -40,9 +40,11 @@ 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 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.
43
+ posts them with a state and optional images to a decision-model provider (OpenRouter,
44
+ Typesafe, OpenAI, Cloudflare, Perplexity, Databricks, or any System One server such
45
+ as Ollama), and returns the same normalized answers with probabilities, confidence,
46
+ legends, and usage whichever vendor served them. Retries follow the official Typesafe
47
+ SDKs.
46
48
  email:
47
49
  - obiefernandez@gmail.com
48
50
  executables: []
@@ -56,9 +58,15 @@ files:
56
58
  - lib/ruby_decision_model/answers.rb
57
59
  - lib/ruby_decision_model/client.rb
58
60
  - lib/ruby_decision_model/errors.rb
61
+ - lib/ruby_decision_model/images.rb
59
62
  - lib/ruby_decision_model/providers.rb
60
63
  - lib/ruby_decision_model/providers/base.rb
64
+ - lib/ruby_decision_model/providers/cloudflare.rb
65
+ - lib/ruby_decision_model/providers/databricks.rb
61
66
  - lib/ruby_decision_model/providers/open_router.rb
67
+ - lib/ruby_decision_model/providers/openai.rb
68
+ - lib/ruby_decision_model/providers/perplexity.rb
69
+ - lib/ruby_decision_model/providers/system_one.rb
62
70
  - lib/ruby_decision_model/providers/typesafe.rb
63
71
  - lib/ruby_decision_model/questions.rb
64
72
  - lib/ruby_decision_model/response.rb
@@ -88,6 +96,6 @@ requirements: []
88
96
  rubygems_version: 3.5.11
89
97
  signing_key:
90
98
  specification_version: 4
91
- summary: 'The decision-model interface for Ruby: OpenRouter and Typesafe behind one
92
- client'
99
+ summary: 'The decision-model interface for Ruby: one client for Jev, gpt-6-luna, Clef,
100
+ pplx-decider, and more'
93
101
  test_files: []