ruby_decision_model 0.0.1 → 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 +104 -0
- data/README.md +338 -14
- data/lib/ruby_decision_model/client.rb +193 -86
- data/lib/ruby_decision_model/errors.rb +13 -4
- data/lib/ruby_decision_model/images.rb +33 -0
- data/lib/ruby_decision_model/providers/base.rb +252 -0
- 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 +50 -0
- 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 +39 -0
- data/lib/ruby_decision_model/providers.rb +78 -0
- data/lib/ruby_decision_model/response.rb +22 -2
- data/lib/ruby_decision_model/retry_policy.rb +155 -0
- data/lib/ruby_decision_model/version.rb +1 -1
- data/lib/ruby_decision_model.rb +15 -0
- metadata +20 -6
|
@@ -0,0 +1,252 @@
|
|
|
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, 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.
|
|
17
|
+
class Base
|
|
18
|
+
attr_reader :api_key
|
|
19
|
+
|
|
20
|
+
def initialize(api_key: nil, base_url: nil)
|
|
21
|
+
@api_key = api_key || ENV.fetch(env_var, nil)
|
|
22
|
+
@base_url = base_url
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Identifier used in error messages and by Client#provider.
|
|
26
|
+
def name
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def env_var
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def default_base_url
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def endpoint_path
|
|
39
|
+
raise NotImplementedError
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def default_model
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Map of alias => canonical model name for this provider.
|
|
47
|
+
def aliases
|
|
48
|
+
{}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Whether this provider reports a per-request cost in usage.
|
|
52
|
+
def reports_cost?
|
|
53
|
+
false
|
|
54
|
+
end
|
|
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.
|
|
83
|
+
def base_url
|
|
84
|
+
(@base_url || default_base_url).to_s.strip.chomp("/")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The model is passed for providers that put it in the path.
|
|
88
|
+
def url(_model = nil)
|
|
89
|
+
"#{base_url}#{endpoint_path}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def api_key?
|
|
93
|
+
!(api_key.nil? || api_key.to_s.strip.empty?)
|
|
94
|
+
end
|
|
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
|
+
|
|
112
|
+
# Nil or blank means the provider default. Known aliases resolve to the
|
|
113
|
+
# provider's canonical name. Anything else passes through untouched.
|
|
114
|
+
def resolve_model(model)
|
|
115
|
+
return default_model if model.nil? || model.to_s.strip.empty?
|
|
116
|
+
|
|
117
|
+
aliases.fetch(model.to_s, model.to_s)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def headers
|
|
121
|
+
headers = {
|
|
122
|
+
"Content-Type" => "application/json",
|
|
123
|
+
"Accept" => "application/json",
|
|
124
|
+
"User-Agent" => "ruby_decision_model/#{VERSION}"
|
|
125
|
+
}
|
|
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
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def usage(parsed)
|
|
165
|
+
raw = parsed.is_a?(Hash) && parsed["usage"].is_a?(Hash) ? parsed["usage"] : {}
|
|
166
|
+
|
|
167
|
+
Response::Usage.new(
|
|
168
|
+
input_tokens: Integer(raw["input_tokens"], exception: false),
|
|
169
|
+
output_tokens: Integer(raw["output_tokens"], exception: false),
|
|
170
|
+
cost: reports_cost? ? Float(raw["cost"], exception: false) : nil
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Keeps the API key out of logs and error output.
|
|
175
|
+
def inspect
|
|
176
|
+
"#<#{self.class.name} name=#{name.inspect} base_url=#{base_url.inspect} api_key=#{api_key? ? '[REDACTED]' : 'nil'}>"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Applies non-nil overrides in place. Used when a caller hands Client a
|
|
180
|
+
# provider instance together with api_key: or base_url:.
|
|
181
|
+
def configure(api_key: nil, base_url: nil)
|
|
182
|
+
@api_key = api_key unless api_key.nil?
|
|
183
|
+
@base_url = base_url unless base_url.nil?
|
|
184
|
+
self
|
|
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
|
|
250
|
+
end
|
|
251
|
+
end
|
|
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
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module RubyDecisionModel
|
|
6
|
+
module Providers
|
|
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.
|
|
10
|
+
ALIASES = {
|
|
11
|
+
"jev" => "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"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def name
|
|
22
|
+
:open_router
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def env_var
|
|
26
|
+
"OPENROUTER_API_KEY"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def default_base_url
|
|
30
|
+
"https://openrouter.ai/api/alpha"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def endpoint_path
|
|
34
|
+
"/decisions"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def default_model
|
|
38
|
+
"typesafe/jev-1.13"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def aliases
|
|
42
|
+
ALIASES
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def reports_cost?
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|