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.
@@ -48,12 +48,16 @@ module RubyDecisionModel
48
48
  end
49
49
  end
50
50
 
51
+ # Raised when any question went unanswered. `missing` lists every such id.
52
+ # `refused` lists the ones the provider explicitly declined, a subset of
53
+ # `missing`. Answers that did arrive are on `answers`.
51
54
  class MissingAnswers < InvalidResponse
52
- attr_reader :missing
55
+ attr_reader :missing, :refused
53
56
 
54
- def initialize(message, answers: {}, missing: [])
57
+ def initialize(message, answers: {}, missing: [], refused: [])
55
58
  super(message, answers: answers)
56
59
  @missing = missing
60
+ @refused = refused
57
61
  end
58
62
  end
59
63
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyDecisionModel
4
+ # Builds the base64 data URLs that Client#ask takes as images:. Every
5
+ # provider that reads images wants them embedded; none fetch a remote URL.
6
+ module Images
7
+ CONTENT_TYPES = {
8
+ ".png" => "image/png",
9
+ ".jpg" => "image/jpeg",
10
+ ".jpeg" => "image/jpeg",
11
+ ".webp" => "image/webp",
12
+ ".gif" => "image/gif"
13
+ }.freeze
14
+
15
+ module_function
16
+
17
+ def data_url(bytes, content_type:)
18
+ raise ArgumentError, "content_type must be an image/* type, got #{content_type.inspect}" unless content_type.to_s.start_with?("image/")
19
+
20
+ # pack("m0") is strict base64 without the base64 gem, which leaves the
21
+ # default gems in Ruby 3.4.
22
+ "data:#{content_type};base64,#{[bytes].pack('m0')}"
23
+ end
24
+
25
+ # Reads a file and infers its type from the extension.
26
+ def from_file(path, content_type: nil)
27
+ content_type ||= CONTENT_TYPES[File.extname(path.to_s).downcase]
28
+ raise ArgumentError, "cannot infer an image type for #{path}; pass content_type:" if content_type.nil?
29
+
30
+ data_url(File.binread(path), content_type: content_type)
31
+ end
32
+ end
33
+ end
@@ -6,8 +6,14 @@ module RubyDecisionModel
6
6
  module Providers
7
7
  # A provider owns everything that differs between decision-model APIs:
8
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.
9
+ # default, which model names are aliases, how the request is encoded, and
10
+ # how the response is read back. Client keeps the public API and
11
+ # delegates these questions here.
12
+ #
13
+ # The defaults speak the System One wire format that Typesafe published
14
+ # with Jev: questions keyed by id, answers keyed by id. A provider whose
15
+ # API differs overrides request_body and normalize_response, translating
16
+ # to and from that shape, so Client only ever sees System One answers.
11
17
  class Base
12
18
  attr_reader :api_key
13
19
 
@@ -47,11 +53,39 @@ module RubyDecisionModel
47
53
  false
48
54
  end
49
55
 
56
+ # Read timeout in seconds when Client gets timeout: nil. The open
57
+ # timeout stays at Client::DEFAULT_OPEN_TIMEOUT.
58
+ def default_timeout
59
+ 5
60
+ end
61
+
62
+ # Whether requests may carry images. Client refuses images: for a
63
+ # provider that says no, before anything goes over the wire.
64
+ def supports_images?
65
+ false
66
+ end
67
+
68
+ # Response header carrying the provider's request id, or nil.
69
+ # Typesafe's, which 0.1.0 read for every provider and System One
70
+ # servers that follow Typesafe send too. Vendors with their own header
71
+ # override it.
72
+ def request_id_header
73
+ "x-typesafe-request-id"
74
+ end
75
+
76
+ # Whether a request without an API key is meaningful. Self-hosted
77
+ # servers often run without auth.
78
+ def requires_api_key?
79
+ true
80
+ end
81
+
82
+ # Values from env files often carry stray whitespace.
50
83
  def base_url
51
- (@base_url || default_base_url).to_s.chomp("/")
84
+ (@base_url || default_base_url).to_s.strip.chomp("/")
52
85
  end
53
86
 
54
- def url
87
+ # The model is passed for providers that put it in the path.
88
+ def url(_model = nil)
55
89
  "#{base_url}#{endpoint_path}"
56
90
  end
57
91
 
@@ -59,6 +93,22 @@ module RubyDecisionModel
59
93
  !(api_key.nil? || api_key.to_s.strip.empty?)
60
94
  end
61
95
 
96
+ # Raises ConfigurationError naming whatever is missing.
97
+ def validate!
98
+ return unless requires_api_key? && !api_key?
99
+
100
+ raise ConfigurationError, "api_key is required for #{name}: pass api_key: or set #{env_var}"
101
+ end
102
+
103
+ # True when validate! would pass. Used to pick a provider from the
104
+ # environment.
105
+ def configured?
106
+ validate!
107
+ true
108
+ rescue ConfigurationError
109
+ false
110
+ end
111
+
62
112
  # Nil or blank means the provider default. Known aliases resolve to the
63
113
  # provider's canonical name. Anything else passes through untouched.
64
114
  def resolve_model(model)
@@ -68,16 +118,47 @@ module RubyDecisionModel
68
118
  end
69
119
 
70
120
  def headers
71
- {
72
- "Authorization" => "Bearer #{api_key}",
121
+ headers = {
73
122
  "Content-Type" => "application/json",
74
123
  "Accept" => "application/json",
75
124
  "User-Agent" => "ruby_decision_model/#{VERSION}"
76
125
  }
77
- end
78
-
79
- def request_body(model:, state:, questions:)
80
- JSON.generate({ "model" => model, "state" => state, "questions" => questions })
126
+ # Keys read from files often end in a newline, which Net::HTTP
127
+ # rejects in a header.
128
+ headers["Authorization"] = "Bearer #{api_key.to_s.strip}" if api_key?
129
+ headers
130
+ end
131
+
132
+ # Client passes images: only when the caller supplied some, so a
133
+ # subclass that does not take images can keep a three-keyword
134
+ # signature.
135
+ def request_body(model:, state:, questions:, images: nil)
136
+ body = { "model" => model, "state" => state, "questions" => questions }
137
+ body["images"] = images if images
138
+ JSON.generate(body)
139
+ end
140
+
141
+ # Turns a parsed success body into the System One shape Client reads:
142
+ # a Hash with "answers" keyed by question id, plus "id", "model", and
143
+ # "usage". `questions` is the Hash the caller asked, for providers that
144
+ # need it to map answers back. Noul answers that arrive without a
145
+ # probability split get one.
146
+ def normalize_response(parsed, questions:)
147
+ fill_noul_probabilities(parsed)
148
+ end
149
+
150
+ # The vendor's reason for a failed request, read from the error body,
151
+ # or nil. Appended to the ApiError message. Knows the shapes the
152
+ # supported APIs use:
153
+ # {"error": {"message": ...}} OpenAI, Perplexity, OpenRouter
154
+ # {"errors": [{"message": ...}]} Cloudflare
155
+ # {"detail": [{"loc", "msg"}]} Typesafe and other FastAPI servers
156
+ # {"message": ...} Databricks REST
157
+ def error_message(body)
158
+ parsed = JSON.parse(body.to_s)
159
+ parsed.is_a?(Hash) ? vendor_text(error_text(parsed)) : nil
160
+ rescue JSON::ParserError, EncodingError
161
+ nil
81
162
  end
82
163
 
83
164
  def usage(parsed)
@@ -102,6 +183,70 @@ module RubyDecisionModel
102
183
  @base_url = base_url unless base_url.nil?
103
184
  self
104
185
  end
186
+
187
+ private
188
+
189
+ ERROR_MESSAGE_LIMIT = 500
190
+
191
+ def error_text(parsed)
192
+ error = parsed["error"]
193
+ return error if error.is_a?(String)
194
+ return error["message"].to_s if error.is_a?(Hash) && error["message"]
195
+
196
+ errors = parsed["errors"]
197
+ if errors.is_a?(Array) && errors.any?
198
+ return errors.map { |item| item.is_a?(Hash) ? item["message"] || item.to_s : item.to_s }.join("; ")
199
+ end
200
+
201
+ detail = parsed["detail"]
202
+ return detail if detail.is_a?(String)
203
+ return detail.map { |item| detail_text(item) }.join("; ") if detail.is_a?(Array) && detail.any?
204
+
205
+ (parsed["message"] || parsed["error_message"])&.to_s
206
+ end
207
+
208
+ def detail_text(item)
209
+ return item.to_s unless item.is_a?(Hash)
210
+
211
+ location = Array(item["loc"]).join(".")
212
+ location.empty? ? item["msg"].to_s : "#{location}: #{item['msg']}"
213
+ end
214
+
215
+ # Vendor-supplied text bound for an exception message, which tends to
216
+ # end up in logs: blank becomes nil, the API key is masked in case a
217
+ # vendor echoes it, and the length is capped.
218
+ def vendor_text(text)
219
+ # Net::HTTP hands back binary bodies; a proxy's error page can carry
220
+ # bytes that are not UTF-8, which strip and gsub would raise on.
221
+ text = text.to_s.dup.force_encoding(Encoding::UTF_8).scrub
222
+ # Control characters could forge log lines or drive a terminal when
223
+ # the vendor echoes caller input; each run becomes one space.
224
+ text = text.gsub(/[[:cntrl:]]+/, " ").strip
225
+ return nil if text.empty?
226
+
227
+ key = api_key.to_s.strip
228
+ text = text.gsub(key, "[REDACTED]") if key.length >= 8
229
+ text.length > ERROR_MESSAGE_LIMIT ? "#{text[0, ERROR_MESSAGE_LIMIT]}..." : text
230
+ end
231
+
232
+ # Several APIs answer a noul with the probability alone. Fill in the
233
+ # two-way split Jev sends so Answers::Noul#probabilities reads the same
234
+ # everywhere.
235
+ def noul_probabilities(answer)
236
+ return answer unless answer.is_a?(Hash) && answer["type"] == "noul"
237
+ return answer if answer["probabilities"].is_a?(Hash) && !answer["probabilities"].empty?
238
+
239
+ probability = answer["noul"]
240
+ return answer unless probability.is_a?(Numeric)
241
+
242
+ answer.merge("probabilities" => { "true" => probability.to_f, "false" => 1.0 - probability.to_f })
243
+ end
244
+
245
+ def fill_noul_probabilities(parsed)
246
+ return parsed unless parsed.is_a?(Hash) && parsed["answers"].is_a?(Hash)
247
+
248
+ parsed.merge("answers" => parsed["answers"].transform_values { |answer| noul_probabilities(answer) })
249
+ end
105
250
  end
106
251
  end
107
252
  end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RubyDecisionModel
6
+ module Providers
7
+ # Cloudflare's Clef and Clef-flash on Workers AI. The body is System One
8
+ # with Cloudflare's images extension; the model also appears in the
9
+ # path, and the account id is required. Responses arrive inside the
10
+ # Workers AI envelope ({"result": ..., "success": true}), which is
11
+ # unwrapped here.
12
+ class Cloudflare < Base
13
+ ALIASES = {
14
+ "cloudflare/clef" => "clef",
15
+ "cloudflare/clef-flash" => "clef-flash",
16
+ "@cf/cloudflare/clef" => "clef",
17
+ "@cf/cloudflare/clef-flash" => "clef-flash"
18
+ }.freeze
19
+
20
+ # Cloudflare's own tooling reads CLOUDFLARE_API_TOKEN; the Clef docs
21
+ # use CLOUDFLARE_AUTH_TOKEN. Either works, in that order.
22
+ FALLBACK_ENV_VAR = "CLOUDFLARE_AUTH_TOKEN"
23
+ ACCOUNT_ENV_VAR = "CLOUDFLARE_ACCOUNT_ID"
24
+
25
+ # Both end up in the request path, so they are held to characters that
26
+ # cannot leave it.
27
+ ACCOUNT_ID_PATTERN = /\A[A-Za-z0-9_-]+\z/
28
+ MODEL_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/
29
+
30
+ attr_reader :account_id
31
+
32
+ def initialize(api_key: nil, base_url: nil, account_id: nil)
33
+ # A blank CLOUDFLARE_API_TOKEN must not hide CLOUDFLARE_AUTH_TOKEN.
34
+ token = [env_var, FALLBACK_ENV_VAR].filter_map { |name| ENV.fetch(name, nil) }.find { |v| !v.strip.empty? }
35
+ super(api_key: api_key || token, base_url: base_url)
36
+ @account_id = (account_id || ENV.fetch(ACCOUNT_ENV_VAR, nil))&.to_s&.strip
37
+ end
38
+
39
+ def name
40
+ :cloudflare
41
+ end
42
+
43
+ def env_var
44
+ "CLOUDFLARE_API_TOKEN"
45
+ end
46
+
47
+ def default_base_url
48
+ "https://api.cloudflare.com/client/v4"
49
+ end
50
+
51
+ def endpoint_path
52
+ "/accounts/#{account_id}/ai/run/@cf/cloudflare"
53
+ end
54
+
55
+ # Checked here as well as at build time, since configure can change the
56
+ # account id on a provider a client already holds.
57
+ def url(model = nil)
58
+ model ||= default_model
59
+ unless account_id.to_s.match?(ACCOUNT_ID_PATTERN) && model.to_s.match?(MODEL_PATTERN)
60
+ raise ConfigurationError,
61
+ "cloudflare account_id and model must stay inside the URL path, got #{account_id.inspect} and #{model.inspect}"
62
+ end
63
+
64
+ "#{base_url}#{endpoint_path}/#{model}"
65
+ end
66
+
67
+ def default_model
68
+ "clef"
69
+ end
70
+
71
+ def aliases
72
+ ALIASES
73
+ end
74
+
75
+ def supports_images?
76
+ true
77
+ end
78
+
79
+ def request_id_header
80
+ "cf-ray"
81
+ end
82
+
83
+ def validate!
84
+ super
85
+ if account_id.nil? || account_id.to_s.strip.empty?
86
+ raise ConfigurationError, "account_id is required for cloudflare: pass account_id: or set #{ACCOUNT_ENV_VAR}"
87
+ end
88
+ return if account_id.to_s.match?(ACCOUNT_ID_PATTERN)
89
+
90
+ raise ConfigurationError, "cloudflare account_id must be letters, digits, - or _, got #{account_id.inspect}"
91
+ end
92
+
93
+ def resolve_model(model)
94
+ resolved = super
95
+ return resolved if resolved.match?(MODEL_PATTERN)
96
+
97
+ raise ConfigurationError, "cloudflare model must be a bare Workers AI name such as clef, got #{model.inspect}"
98
+ end
99
+
100
+ # Unwraps the Workers AI envelope. A body that says success: false
101
+ # carries no answers, so its errors become the exception message.
102
+ def normalize_response(parsed, questions:)
103
+ if parsed["success"] == false
104
+ detail = vendor_text(error_text(parsed))
105
+ raise InvalidResponse, ["cloudflare reported failure", detail].compact.join(": ")
106
+ end
107
+
108
+ body = parsed["result"].is_a?(Hash) ? parsed["result"] : parsed
109
+ super(body, questions: questions)
110
+ end
111
+
112
+ def configure(api_key: nil, base_url: nil, account_id: nil)
113
+ super(api_key: api_key, base_url: base_url)
114
+ @account_id = account_id.to_s.strip unless account_id.nil?
115
+ self
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module RubyDecisionModel
6
+ module Providers
7
+ # Databricks ai_decide over REST (beta). Questions are System One, but
8
+ # the workspace serves one managed model, so the body has no model and
9
+ # Client#model is nil. Answers arrive under "response", and a noul
10
+ # carries "probability" where System One says "noul". Usage and ids are
11
+ # not reported.
12
+ class Databricks < Base
13
+ HOST_ENV_VAR = "DATABRICKS_HOST"
14
+
15
+ def initialize(api_key: nil, base_url: nil)
16
+ super
17
+ @base_url ||= ENV.fetch(HOST_ENV_VAR, nil)
18
+ end
19
+
20
+ def name
21
+ :databricks
22
+ end
23
+
24
+ def env_var
25
+ "DATABRICKS_TOKEN"
26
+ end
27
+
28
+ def default_base_url
29
+ nil
30
+ end
31
+
32
+ # DATABRICKS_HOST is often set without a scheme.
33
+ def base_url
34
+ host = super
35
+ return host if host.empty? || host.match?(%r{\Ahttps?://})
36
+
37
+ "https://#{host}"
38
+ end
39
+
40
+ # Beta serverless endpoint with no published latency figures.
41
+ def default_timeout
42
+ 30
43
+ end
44
+
45
+ def endpoint_path
46
+ "/api/2.0/ai-functions/ai-decide"
47
+ end
48
+
49
+ def default_model
50
+ nil
51
+ end
52
+
53
+ def resolve_model(model)
54
+ return nil if model.nil? || model.to_s.strip.empty?
55
+
56
+ raise ConfigurationError, "databricks serves ai_decide's managed model and takes no model: (got #{model.inspect})"
57
+ end
58
+
59
+ def validate!
60
+ super
61
+ return unless base_url.empty?
62
+
63
+ raise ConfigurationError, "base_url is required for databricks: pass base_url: or set #{HOST_ENV_VAR}"
64
+ end
65
+
66
+ def request_body(model:, state:, questions:)
67
+ JSON.generate("state" => state, "questions" => questions)
68
+ end
69
+
70
+ def normalize_response(parsed, questions:)
71
+ if parsed["response"].nil? && parsed["error_message"]
72
+ raise InvalidResponse, "databricks ai_decide error: #{vendor_text(parsed['error_message'])}"
73
+ end
74
+
75
+ response = parsed["response"].is_a?(Hash) ? parsed["response"] : {}
76
+ answers = response["answers"].is_a?(Hash) ? response["answers"] : {}
77
+ answers = answers.transform_values do |answer|
78
+ next answer unless answer.is_a?(Hash) && answer["type"] == "noul" && !answer.key?("noul")
79
+
80
+ answer.merge("noul" => answer["probability"])
81
+ end
82
+
83
+ super({ "answers" => answers }, questions: questions)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -5,9 +5,17 @@ require_relative "base"
5
5
  module RubyDecisionModel
6
6
  module Providers
7
7
  class OpenRouter < Base
8
+ # Short names resolve to the decision models OpenRouter routes, so the
9
+ # same model: works here and on each vendor's own provider.
8
10
  ALIASES = {
9
11
  "jev" => "typesafe/jev-1.13",
10
- "jev-latest" => "typesafe/jev-1.13"
12
+ "jev-latest" => "typesafe/jev-1.13",
13
+ "luna" => "openai/gpt-6-luna-decisions",
14
+ "gpt-6-luna" => "openai/gpt-6-luna-decisions",
15
+ "clef" => "cloudflare/clef",
16
+ "clef-flash" => "cloudflare/clef-flash",
17
+ "pplx-decider" => "perplexity/pplx-decider-v1-27b",
18
+ "pplx-decider-v1-27b" => "perplexity/pplx-decider-v1-27b"
11
19
  }.freeze
12
20
 
13
21
  def name