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
|
@@ -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
|
|
@@ -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
|
|
29
|
-
|
|
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
|
|
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.
|
|
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
|
|
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,
|
data/lib/ruby_decision_model.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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:
|
|
92
|
-
|
|
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: []
|