boxcars 0.8.6 → 0.8.8
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/Gemfile.lock +50 -44
- data/lib/boxcars/boxcar/json_engine_boxcar.rb +1 -1
- data/lib/boxcars/engine/anthropic.rb +5 -18
- data/lib/boxcars/engine/cohere.rb +4 -17
- data/lib/boxcars/engine/gemini_ai.rb +4 -16
- data/lib/boxcars/engine/gpt4all_eng.rb +1 -0
- data/lib/boxcars/engine/groq.rb +4 -14
- data/lib/boxcars/engine/intelligence_base.rb +3 -4
- data/lib/boxcars/engine/ollama.rb +1 -0
- data/lib/boxcars/engine/openai.rb +174 -13
- data/lib/boxcars/engine/perplexityai.rb +48 -6
- data/lib/boxcars/engine.rb +34 -2
- data/lib/boxcars/engines.rb +8 -5
- data/lib/boxcars/vector_store/embed_via_tensorflow.rb +1 -0
- data/lib/boxcars/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8261b5fd1cf581d141ae39fd3d567abd9751a98c6227c3f56c3d221275eb08d1
|
4
|
+
data.tar.gz: 5bb1a1f94a6076fa9f72542aa7117bd981d7f5191781cea917822109e93ace6c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b763fabf8eb07f1d52dc558d51af423ebd96761fece00850862c41fefa1c342524ace6a733e097e4cd6696b80d3b6baabfb043a81c3734fa2f4b8f8f45b58a07
|
7
|
+
data.tar.gz: 264fcc8c5ba7e064a5f295ee1b8427ffaeccb072eb8bf89ac64e146214ce3485a317ba9a92ed8a874ae62dc86b5f973b1cea6d71f0361e8300c46ae956bd2151
|
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
boxcars (0.8.
|
4
|
+
boxcars (0.8.8)
|
5
5
|
faraday-retry (~> 2.0)
|
6
6
|
google_search_results (~> 2.2)
|
7
7
|
gpt4all (~> 0.0.5)
|
@@ -14,13 +14,13 @@ PATH
|
|
14
14
|
GEM
|
15
15
|
remote: https://rubygems.org/
|
16
16
|
specs:
|
17
|
-
activemodel (7.2.2.
|
18
|
-
activesupport (= 7.2.2.
|
19
|
-
activerecord (7.2.2.
|
20
|
-
activemodel (= 7.2.2.
|
21
|
-
activesupport (= 7.2.2.
|
17
|
+
activemodel (7.2.2.2)
|
18
|
+
activesupport (= 7.2.2.2)
|
19
|
+
activerecord (7.2.2.2)
|
20
|
+
activemodel (= 7.2.2.2)
|
21
|
+
activesupport (= 7.2.2.2)
|
22
22
|
timeout (>= 0.4.0)
|
23
|
-
activesupport (7.2.2.
|
23
|
+
activesupport (7.2.2.2)
|
24
24
|
base64
|
25
25
|
benchmark (>= 0.3)
|
26
26
|
bigdecimal
|
@@ -47,7 +47,7 @@ GEM
|
|
47
47
|
protocol-http1 (~> 0.19.0)
|
48
48
|
protocol-http2 (~> 0.16.0)
|
49
49
|
traces (>= 0.10.0)
|
50
|
-
async-http-faraday (0.22.
|
50
|
+
async-http-faraday (0.22.1)
|
51
51
|
async-http (~> 0.42)
|
52
52
|
faraday
|
53
53
|
async-io (1.43.2)
|
@@ -59,7 +59,7 @@ GEM
|
|
59
59
|
bigdecimal (3.2.2)
|
60
60
|
concurrent-ruby (1.3.5)
|
61
61
|
connection_pool (2.5.3)
|
62
|
-
console (1.
|
62
|
+
console (1.33.0)
|
63
63
|
fiber-annotation
|
64
64
|
fiber-local (~> 1.1)
|
65
65
|
json
|
@@ -74,10 +74,10 @@ GEM
|
|
74
74
|
domain_name (0.6.20240107)
|
75
75
|
dotenv (3.1.8)
|
76
76
|
drb (2.2.3)
|
77
|
-
dynamicschema (1.0.
|
78
|
-
erb (5.0.
|
77
|
+
dynamicschema (1.0.1)
|
78
|
+
erb (5.0.2)
|
79
79
|
event_stream_parser (1.0.0)
|
80
|
-
faraday (2.13.
|
80
|
+
faraday (2.13.4)
|
81
81
|
faraday-net_http (>= 2.0, < 3.5)
|
82
82
|
json
|
83
83
|
logger
|
@@ -119,12 +119,12 @@ GEM
|
|
119
119
|
faraday (~> 2.7)
|
120
120
|
json-repair (~> 0.2)
|
121
121
|
mime-types (~> 3.6)
|
122
|
-
io-console (0.8.
|
122
|
+
io-console (0.8.1)
|
123
123
|
irb (1.15.2)
|
124
124
|
pp (>= 0.6.0)
|
125
125
|
rdoc (>= 4.0.0)
|
126
126
|
reline (>= 0.4.2)
|
127
|
-
json (2.
|
127
|
+
json (2.13.2)
|
128
128
|
json-repair (0.2.0)
|
129
129
|
language_server-protocol (3.17.0.5)
|
130
130
|
lint_roller (1.1.0)
|
@@ -132,41 +132,47 @@ GEM
|
|
132
132
|
mime-types (3.7.0)
|
133
133
|
logger
|
134
134
|
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
135
|
-
mime-types-data (3.2025.
|
135
|
+
mime-types-data (3.2025.0819)
|
136
136
|
minitest (5.25.5)
|
137
|
-
multi_json (1.
|
137
|
+
multi_json (1.17.0)
|
138
138
|
multipart-post (2.4.1)
|
139
139
|
net-http (0.6.0)
|
140
140
|
uri
|
141
141
|
netrc (0.11.0)
|
142
142
|
nio4r (2.7.4)
|
143
|
-
nokogiri (1.18.
|
143
|
+
nokogiri (1.18.9-aarch64-linux-gnu)
|
144
144
|
racc (~> 1.4)
|
145
|
-
nokogiri (1.18.
|
145
|
+
nokogiri (1.18.9-aarch64-linux-musl)
|
146
146
|
racc (~> 1.4)
|
147
|
-
nokogiri (1.18.
|
147
|
+
nokogiri (1.18.9-arm-linux-gnu)
|
148
148
|
racc (~> 1.4)
|
149
|
-
nokogiri (1.18.
|
149
|
+
nokogiri (1.18.9-arm-linux-musl)
|
150
150
|
racc (~> 1.4)
|
151
|
-
nokogiri (1.18.
|
151
|
+
nokogiri (1.18.9-arm64-darwin)
|
152
152
|
racc (~> 1.4)
|
153
|
-
nokogiri (1.18.
|
153
|
+
nokogiri (1.18.9-x86_64-darwin)
|
154
154
|
racc (~> 1.4)
|
155
|
-
nokogiri (1.18.
|
155
|
+
nokogiri (1.18.9-x86_64-linux-gnu)
|
156
156
|
racc (~> 1.4)
|
157
|
-
nokogiri (1.18.
|
157
|
+
nokogiri (1.18.9-x86_64-linux-musl)
|
158
158
|
racc (~> 1.4)
|
159
159
|
octokit (4.25.1)
|
160
160
|
faraday (>= 1, < 3)
|
161
161
|
sawyer (~> 0.9)
|
162
162
|
os (1.1.4)
|
163
163
|
parallel (1.27.0)
|
164
|
-
parser (3.3.
|
164
|
+
parser (3.3.9.0)
|
165
165
|
ast (~> 2.4.1)
|
166
166
|
racc
|
167
|
-
pg (1.
|
167
|
+
pg (1.6.1)
|
168
|
+
pg (1.6.1-aarch64-linux)
|
169
|
+
pg (1.6.1-aarch64-linux-musl)
|
170
|
+
pg (1.6.1-arm64-darwin)
|
171
|
+
pg (1.6.1-x86_64-darwin)
|
172
|
+
pg (1.6.1-x86_64-linux)
|
173
|
+
pg (1.6.1-x86_64-linux-musl)
|
168
174
|
pgvector (0.2.2)
|
169
|
-
posthog-ruby (3.
|
175
|
+
posthog-ruby (3.1.2)
|
170
176
|
concurrent-ruby (~> 1)
|
171
177
|
pp (0.6.2)
|
172
178
|
prettyprint
|
@@ -186,11 +192,11 @@ GEM
|
|
186
192
|
racc (1.8.1)
|
187
193
|
rainbow (3.1.1)
|
188
194
|
rake (13.3.0)
|
189
|
-
rdoc (6.14.
|
195
|
+
rdoc (6.14.2)
|
190
196
|
erb
|
191
197
|
psych (>= 4.0.0)
|
192
|
-
regexp_parser (2.
|
193
|
-
reline (0.6.
|
198
|
+
regexp_parser (2.11.2)
|
199
|
+
reline (0.6.2)
|
194
200
|
io-console (~> 0.5)
|
195
201
|
rest-client (2.1.0)
|
196
202
|
http-accept (>= 1.7.0, < 2.0)
|
@@ -210,8 +216,8 @@ GEM
|
|
210
216
|
rspec-mocks (3.13.5)
|
211
217
|
diff-lcs (>= 1.2.0, < 2.0)
|
212
218
|
rspec-support (~> 3.13.0)
|
213
|
-
rspec-support (3.13.
|
214
|
-
rubocop (1.
|
219
|
+
rspec-support (3.13.5)
|
220
|
+
rubocop (1.80.0)
|
215
221
|
json (~> 2.3)
|
216
222
|
language_server-protocol (~> 3.17.0.2)
|
217
223
|
lint_roller (~> 1.1.0)
|
@@ -219,10 +225,10 @@ GEM
|
|
219
225
|
parser (>= 3.3.0.2)
|
220
226
|
rainbow (>= 2.2.2, < 4.0)
|
221
227
|
regexp_parser (>= 2.9.3, < 3.0)
|
222
|
-
rubocop-ast (>= 1.
|
228
|
+
rubocop-ast (>= 1.46.0, < 2.0)
|
223
229
|
ruby-progressbar (~> 1.7)
|
224
230
|
unicode-display_width (>= 2.4.0, < 4.0)
|
225
|
-
rubocop-ast (1.
|
231
|
+
rubocop-ast (1.46.0)
|
226
232
|
parser (>= 3.3.7.2)
|
227
233
|
prism (~> 1.4)
|
228
234
|
rubocop-rake (0.6.0)
|
@@ -234,7 +240,7 @@ GEM
|
|
234
240
|
event_stream_parser (>= 0.3.0, < 2.0.0)
|
235
241
|
faraday (>= 1)
|
236
242
|
faraday-multipart (>= 1)
|
237
|
-
ruby-openai (8.
|
243
|
+
ruby-openai (8.2.0)
|
238
244
|
event_stream_parser (>= 0.3.0, < 2.0.0)
|
239
245
|
faraday (>= 1)
|
240
246
|
faraday-multipart (>= 1)
|
@@ -243,19 +249,19 @@ GEM
|
|
243
249
|
addressable (>= 2.3.5)
|
244
250
|
faraday (>= 0.17.3, < 3)
|
245
251
|
securerandom (0.4.1)
|
246
|
-
sqlite3 (2.7.
|
247
|
-
sqlite3 (2.7.
|
248
|
-
sqlite3 (2.7.
|
249
|
-
sqlite3 (2.7.
|
250
|
-
sqlite3 (2.7.
|
251
|
-
sqlite3 (2.7.
|
252
|
-
sqlite3 (2.7.
|
253
|
-
sqlite3 (2.7.
|
252
|
+
sqlite3 (2.7.3-aarch64-linux-gnu)
|
253
|
+
sqlite3 (2.7.3-aarch64-linux-musl)
|
254
|
+
sqlite3 (2.7.3-arm-linux-gnu)
|
255
|
+
sqlite3 (2.7.3-arm-linux-musl)
|
256
|
+
sqlite3 (2.7.3-arm64-darwin)
|
257
|
+
sqlite3 (2.7.3-x86_64-darwin)
|
258
|
+
sqlite3 (2.7.3-x86_64-linux-gnu)
|
259
|
+
sqlite3 (2.7.3-x86_64-linux-musl)
|
254
260
|
stringio (3.1.7)
|
255
261
|
strings-ansi (0.2.0)
|
256
262
|
timeout (0.4.3)
|
257
263
|
timers (4.4.0)
|
258
|
-
traces (0.
|
264
|
+
traces (0.17.0)
|
259
265
|
tty-cursor (0.7.1)
|
260
266
|
tty-progressbar (0.18.3)
|
261
267
|
strings-ansi (~> 0.2)
|
@@ -7,6 +7,7 @@ module Boxcars
|
|
7
7
|
# rubocop:disable Metrics/ClassLength
|
8
8
|
class Anthropic < Engine
|
9
9
|
include UnifiedObservability
|
10
|
+
|
10
11
|
attr_reader :prompts, :llm_params, :model_kwargs, :batch_size
|
11
12
|
|
12
13
|
# The default parameters to use when asking the engine.
|
@@ -122,23 +123,9 @@ module Boxcars
|
|
122
123
|
end
|
123
124
|
end
|
124
125
|
|
125
|
-
#
|
126
|
-
|
127
|
-
|
128
|
-
# @raise [KeyError] if there is an issue with the access token.
|
129
|
-
# @raise [ValueError] if the response is not valid.
|
130
|
-
def check_response(response, must_haves: %w[completion])
|
131
|
-
if response['error']
|
132
|
-
code = response.dig('error', 'code')
|
133
|
-
msg = response.dig('error', 'message') || 'unknown error'
|
134
|
-
raise KeyError, "ANTHOPIC_API_KEY not valid" if code == 'invalid_api_key'
|
135
|
-
|
136
|
-
raise ValueError, "Anthropic error: #{msg}"
|
137
|
-
end
|
138
|
-
|
139
|
-
must_haves.each do |key|
|
140
|
-
raise ValueError, "Expecting key #{key} in response" unless response.key?(key)
|
141
|
-
end
|
126
|
+
# validate_response! method uses the base implementation with Anthropic-specific must_haves
|
127
|
+
def validate_response!(response, must_haves: %w[completion])
|
128
|
+
super
|
142
129
|
end
|
143
130
|
|
144
131
|
# Call out to OpenAI's endpoint with k unique prompts.
|
@@ -155,7 +142,7 @@ module Boxcars
|
|
155
142
|
prompts.each_slice(batch_size) do |sub_prompts|
|
156
143
|
sub_prompts.each do |sprompts, inputs|
|
157
144
|
response = client(prompt: sprompts, inputs:, **params)
|
158
|
-
|
145
|
+
validate_response!(response)
|
159
146
|
choices << response
|
160
147
|
end
|
161
148
|
end
|
@@ -5,6 +5,7 @@ module Boxcars
|
|
5
5
|
# A engine that uses Cohere's API.
|
6
6
|
class Cohere < Engine
|
7
7
|
include UnifiedObservability
|
8
|
+
|
8
9
|
attr_reader :prompts, :llm_params, :model_kwargs, :batch_size
|
9
10
|
|
10
11
|
# The default parameters to use when asking the engine.
|
@@ -114,23 +115,9 @@ module Boxcars
|
|
114
115
|
llm_params
|
115
116
|
end
|
116
117
|
|
117
|
-
#
|
118
|
-
|
119
|
-
|
120
|
-
# @raise [KeyError] if there is an issue with the access token.
|
121
|
-
# @raise [ValueError] if the response is not valid.
|
122
|
-
def check_response(response, must_haves: %w[completion])
|
123
|
-
if response['error']
|
124
|
-
code = response.dig('error', 'code')
|
125
|
-
msg = response.dig('error', 'message') || 'unknown error'
|
126
|
-
raise KeyError, "ANTHOPIC_API_KEY not valid" if code == 'invalid_api_key'
|
127
|
-
|
128
|
-
raise ValueError, "Cohere error: #{msg}"
|
129
|
-
end
|
130
|
-
|
131
|
-
must_haves.each do |key|
|
132
|
-
raise ValueError, "Expecting key #{key} in response" unless response.key?(key)
|
133
|
-
end
|
118
|
+
# validate_response! method uses the base implementation with Cohere-specific must_haves
|
119
|
+
def validate_response!(response, must_haves: %w[completion])
|
120
|
+
super
|
134
121
|
end
|
135
122
|
|
136
123
|
# the engine type
|
@@ -7,6 +7,7 @@ module Boxcars
|
|
7
7
|
# A engine that uses GeminiAI's API via an OpenAI-compatible interface.
|
8
8
|
class GeminiAi < Engine
|
9
9
|
include UnifiedObservability
|
10
|
+
|
10
11
|
attr_reader :prompts, :llm_params, :model_kwargs, :batch_size # Corrected typo llm_parmas to llm_params
|
11
12
|
|
12
13
|
DEFAULT_PARAMS = {
|
@@ -178,22 +179,9 @@ module Boxcars
|
|
178
179
|
end
|
179
180
|
end
|
180
181
|
|
181
|
-
#
|
182
|
-
|
183
|
-
|
184
|
-
if response['error'].is_a?(Hash)
|
185
|
-
code = response.dig('error', 'code')
|
186
|
-
msg = response.dig('error', 'message') || 'unknown error'
|
187
|
-
# GEMINI_API_TOKEN is not standard, usually it's an API key.
|
188
|
-
# This check might need to align with actual error codes from Gemini.
|
189
|
-
raise KeyError, "Gemini API Key not valid or permission issue" if ['invalid_api_key', 'permission_denied'].include?(code)
|
190
|
-
|
191
|
-
raise ValueError, "GeminiAI error: #{msg}"
|
192
|
-
end
|
193
|
-
|
194
|
-
# Check for either 'choices' (OpenAI style) or 'candidates' (Gemini native style)
|
195
|
-
has_valid_content = must_haves.any? { |key| response.key?(key) && !response[key].empty? }
|
196
|
-
raise ValueError, "Expecting key like 'choices' or 'candidates' in response" unless has_valid_content
|
182
|
+
# validate_response! method uses the base implementation with Gemini-specific must_haves
|
183
|
+
def validate_response!(response, must_haves: %w[choices candidates])
|
184
|
+
super
|
197
185
|
end
|
198
186
|
end
|
199
187
|
end
|
data/lib/boxcars/engine/groq.rb
CHANGED
@@ -7,6 +7,7 @@ module Boxcars
|
|
7
7
|
# A engine that uses Groq's API.
|
8
8
|
class Groq < Engine
|
9
9
|
include UnifiedObservability
|
10
|
+
|
10
11
|
attr_reader :prompts, :groq_params, :model_kwargs, :batch_size
|
11
12
|
|
12
13
|
DEFAULT_PARAMS = {
|
@@ -157,20 +158,9 @@ module Boxcars
|
|
157
158
|
end
|
158
159
|
end
|
159
160
|
|
160
|
-
#
|
161
|
-
def
|
162
|
-
|
163
|
-
code = response.dig('error', 'code')
|
164
|
-
msg = response.dig('error', 'message') || 'unknown error'
|
165
|
-
# GROQ_API_TOKEN is not standard, usually it's an API key.
|
166
|
-
raise KeyError, "Groq API Key not valid or permission issue" if ['invalid_api_key', 'permission_denied'].include?(code)
|
167
|
-
|
168
|
-
raise ValueError, "Groq error: #{msg}"
|
169
|
-
end
|
170
|
-
|
171
|
-
must_haves.each do |key|
|
172
|
-
raise ValueError, "Expecting key #{key} in response" unless response.key?(key) && !response[key].empty?
|
173
|
-
end
|
161
|
+
# validate_response! method uses the base implementation with Groq-specific must_haves
|
162
|
+
def validate_response!(response, must_haves: %w[choices])
|
163
|
+
super
|
174
164
|
end
|
175
165
|
end
|
176
166
|
end
|
@@ -7,6 +7,7 @@ module Boxcars
|
|
7
7
|
# A Base class for all Intelligence Engines
|
8
8
|
class IntelligenceBase < Engine
|
9
9
|
include Boxcars::UnifiedObservability
|
10
|
+
|
10
11
|
attr_reader :provider, :all_params
|
11
12
|
|
12
13
|
# The base Intelligence Engine is used by other engines to generate output from prompts
|
@@ -119,10 +120,8 @@ module Boxcars
|
|
119
120
|
|
120
121
|
private
|
121
122
|
|
122
|
-
def
|
123
|
-
|
124
|
-
|
125
|
-
raise Error, "Invalid response from #{provider}: #{response}"
|
123
|
+
def validate_response!(response, must_haves: %w[choices])
|
124
|
+
super
|
126
125
|
end
|
127
126
|
end
|
128
127
|
end
|
@@ -6,11 +6,13 @@ require "securerandom"
|
|
6
6
|
|
7
7
|
module Boxcars
|
8
8
|
# Engine that talks to OpenAI’s REST API.
|
9
|
-
class Openai < Engine
|
9
|
+
class Openai < Engine # rubocop:disable Metrics/ClassLength
|
10
|
+
# include Boxcars::EngineHelpers
|
10
11
|
include UnifiedObservability
|
11
12
|
|
12
13
|
CHAT_MODEL_REGEX = /(^gpt-4)|(-turbo\b)|(^o\d)|(gpt-3\.5-turbo)/
|
13
14
|
O_SERIES_REGEX = /^o/
|
15
|
+
GPT5_MODEL_REGEX = /\Agpt-[56].*/
|
14
16
|
|
15
17
|
DEFAULT_PARAMS = {
|
16
18
|
model: "gpt-4o-mini",
|
@@ -80,9 +82,9 @@ module Boxcars
|
|
80
82
|
def run(question, **)
|
81
83
|
prompt = Prompt.new(template: question)
|
82
84
|
raw_json = client(prompt:, inputs: {}, **)
|
83
|
-
|
84
|
-
|
85
|
-
|
85
|
+
ans = extract_answer(raw_json)
|
86
|
+
Boxcars.debug("Answer: #{ans}", :cyan)
|
87
|
+
ans
|
86
88
|
end
|
87
89
|
|
88
90
|
# Expose the defaults so callers can introspect or dup/merge them
|
@@ -100,21 +102,23 @@ module Boxcars
|
|
100
102
|
end
|
101
103
|
|
102
104
|
# -- Public helper -------------------------------------------------------------
|
103
|
-
# Some callers outside this class still invoke `
|
105
|
+
# Some callers outside this class still invoke `validate_response!` directly.
|
104
106
|
# It simply raises if the JSON body contains an "error" payload.
|
105
|
-
def
|
106
|
-
if (
|
107
|
-
|
107
|
+
def validate_response!(response, must_haves: %w[choices])
|
108
|
+
if response.is_a?(Hash) && response.key?("output")
|
109
|
+
super(response, must_haves: %w[output])
|
110
|
+
else
|
111
|
+
super
|
108
112
|
end
|
109
|
-
|
110
|
-
true
|
111
113
|
end
|
112
114
|
|
113
115
|
private
|
114
116
|
|
115
117
|
# -- Request construction ---------------------------------------------------
|
116
118
|
def build_api_request(prompt_object, inputs, params, chat:)
|
117
|
-
if
|
119
|
+
if gpt5_model?(params[:model])
|
120
|
+
build_responses_params(prompt_object, inputs, params.dup)
|
121
|
+
elsif chat
|
118
122
|
build_chat_params(prompt_object, inputs, params.dup)
|
119
123
|
else
|
120
124
|
build_completion_params(prompt_object, inputs, params.dup)
|
@@ -137,9 +141,51 @@ module Boxcars
|
|
137
141
|
{ prompt: prompt_txt }.merge(params).tap { |h| h.delete(:messages) }
|
138
142
|
end
|
139
143
|
|
144
|
+
def build_responses_params(prompt_object, inputs, params)
|
145
|
+
po = if prompt_object.is_a?(Boxcars::Prompt)
|
146
|
+
prompt_object
|
147
|
+
else
|
148
|
+
Boxcars::Prompt.new(template: prompt_object.to_s)
|
149
|
+
end
|
150
|
+
|
151
|
+
msg_hash = po.as_messages(inputs)
|
152
|
+
messages = msg_hash[:messages].is_a?(Array) ? msg_hash[:messages] : []
|
153
|
+
input_str = messages_to_input(messages)
|
154
|
+
|
155
|
+
p = params.dup
|
156
|
+
p.delete(:messages)
|
157
|
+
p.delete(:response_format)
|
158
|
+
p.delete(:stop)
|
159
|
+
p.delete(:temperature)
|
160
|
+
p[:max_output_tokens] = p.delete(:max_tokens) if p.key?(:max_tokens) && !p.key?(:max_output_tokens)
|
161
|
+
if (effort = p.delete(:reasoning_effort))
|
162
|
+
p[:reasoning] = { effort: effort }
|
163
|
+
end
|
164
|
+
|
165
|
+
formatted = { model: p[:model], input: input_str, _use_responses_api: true }
|
166
|
+
p.each { |k, v| formatted[k] = v unless k == :model }
|
167
|
+
formatted
|
168
|
+
end
|
169
|
+
|
170
|
+
def messages_to_input(messages)
|
171
|
+
return "" unless messages.is_a?(Array)
|
172
|
+
|
173
|
+
messages.map { |m| "#{m[:role]}: #{m[:content]}" }.join("\n")
|
174
|
+
end
|
175
|
+
|
140
176
|
# -- API call / response ----------------------------------------------------
|
141
177
|
def execute_api_call(client, chat_mode, api_request)
|
142
|
-
if
|
178
|
+
if api_request[:_use_responses_api]
|
179
|
+
call_params = api_request.dup
|
180
|
+
call_params.delete(:_use_responses_api)
|
181
|
+
Boxcars.debug("Input after formatting:\n#{call_params[:input]}", :cyan) if Boxcars.configuration.log_prompts
|
182
|
+
unless client.respond_to?(:responses)
|
183
|
+
raise StandardError,
|
184
|
+
"OpenAI Responses API not supported by installed ruby-openai gem. Upgrade ruby-openai to >7.0 version."
|
185
|
+
end
|
186
|
+
|
187
|
+
client.responses.create(parameters: call_params)
|
188
|
+
elsif chat_mode
|
143
189
|
log_messages_debug(api_request[:messages]) if Boxcars.configuration.log_prompts
|
144
190
|
client.chat(parameters: api_request)
|
145
191
|
else
|
@@ -191,8 +237,117 @@ module Boxcars
|
|
191
237
|
raise Error, "OpenAI: Could not extract answer from choices"
|
192
238
|
end
|
193
239
|
|
240
|
+
def extract_answer_from_output(output_items) # rubocop:disable Metrics/PerceivedComplexity,Metrics/MethodLength
|
241
|
+
return nil unless output_items.is_a?(Array) && output_items.any?
|
242
|
+
|
243
|
+
texts = []
|
244
|
+
|
245
|
+
output_items.each do |i| # rubocop:disable Metrics/BlockLength
|
246
|
+
next unless i.is_a?(Hash)
|
247
|
+
|
248
|
+
case i["type"]
|
249
|
+
when "output_text"
|
250
|
+
content = i["content"]
|
251
|
+
if content.is_a?(Array)
|
252
|
+
# rubocop:disable Metrics/BlockNesting
|
253
|
+
texts << content.filter_map { |c|
|
254
|
+
if c.is_a?(Hash)
|
255
|
+
if c["text"].is_a?(String)
|
256
|
+
c["text"]
|
257
|
+
else
|
258
|
+
(c["text"].is_a?(Hash) ? (c["text"]["value"] || c["text"]["text"]) : nil)
|
259
|
+
end
|
260
|
+
end
|
261
|
+
}.join
|
262
|
+
# rubocop:enable Metrics/BlockNesting
|
263
|
+
elsif content.is_a?(String)
|
264
|
+
texts << content
|
265
|
+
end
|
266
|
+
# Some Responses payloads may include a direct "text" field.
|
267
|
+
if i["text"].is_a?(String)
|
268
|
+
texts << i["text"]
|
269
|
+
elsif i["text"].is_a?(Hash)
|
270
|
+
texts << (i["text"]["value"] || i["text"]["text"])
|
271
|
+
end
|
272
|
+
when "message"
|
273
|
+
content = i["content"]
|
274
|
+
if content.is_a?(Array)
|
275
|
+
parts = content.filter_map do |c|
|
276
|
+
next unless c.is_a?(Hash) && ["output_text", "text"].include?(c["type"])
|
277
|
+
|
278
|
+
t = c["text"]
|
279
|
+
if t.is_a?(String)
|
280
|
+
t
|
281
|
+
elsif t.is_a?(Hash)
|
282
|
+
t["value"] || t["text"]
|
283
|
+
elsif c["content"].is_a?(String)
|
284
|
+
c["content"]
|
285
|
+
end
|
286
|
+
end
|
287
|
+
texts << parts.join
|
288
|
+
end
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
return nil if texts.empty?
|
293
|
+
|
294
|
+
texts.join("\n").strip
|
295
|
+
end
|
296
|
+
|
297
|
+
def extract_answer(json)
|
298
|
+
if json.is_a?(Hash)
|
299
|
+
if json["output_text"].is_a?(String) && !json["output_text"].strip.empty?
|
300
|
+
return json["output_text"].strip
|
301
|
+
elsif json["output_text"].is_a?(Array)
|
302
|
+
joined = json["output_text"].map do |t|
|
303
|
+
if t.is_a?(String)
|
304
|
+
t
|
305
|
+
elsif t.is_a?(Hash)
|
306
|
+
t["value"] || t["text"] || t["content"]
|
307
|
+
end
|
308
|
+
end.compact.join("\n").strip
|
309
|
+
return joined unless joined.empty?
|
310
|
+
end
|
311
|
+
|
312
|
+
if json["output"].is_a?(Array)
|
313
|
+
out = extract_answer_from_output(json["output"])
|
314
|
+
return out unless out.nil? || out.strip.empty?
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
choices = json["choices"]
|
319
|
+
return extract_answer_from_choices(choices) if choices
|
320
|
+
|
321
|
+
# Fallback: attempt to find any text in nested Responses payloads
|
322
|
+
fallback = deep_extract_texts(json)
|
323
|
+
return fallback unless fallback.nil? || fallback.strip.empty?
|
324
|
+
|
325
|
+
raise Error, "OpenAI: Could not extract answer"
|
326
|
+
end
|
327
|
+
|
328
|
+
def deep_extract_texts(obj)
|
329
|
+
texts = []
|
330
|
+
stack = [obj]
|
331
|
+
while (cur = stack.pop)
|
332
|
+
case cur
|
333
|
+
when Hash
|
334
|
+
texts << cur["output_text"] if cur["output_text"].is_a?(String)
|
335
|
+
texts << cur["text"] if cur["text"].is_a?(String)
|
336
|
+
texts << cur["content"] if cur["content"].is_a?(String)
|
337
|
+
cur.each_value do |v|
|
338
|
+
stack << v if v.is_a?(Hash) || v.is_a?(Array)
|
339
|
+
end
|
340
|
+
when Array
|
341
|
+
cur.each { |v| stack << v if v.is_a?(Hash) || v.is_a?(Array) }
|
342
|
+
end
|
343
|
+
end
|
344
|
+
aggregated = texts.map { |t| t.to_s.strip }.reject(&:empty?).join("\n")
|
345
|
+
aggregated.empty? ? nil : aggregated
|
346
|
+
end
|
347
|
+
|
194
348
|
# -- Utility helpers --------------------------------------------------------
|
195
349
|
def chat_model?(model_name) = CHAT_MODEL_REGEX.match?(model_name)
|
350
|
+
def gpt5_model?(model_name) = GPT5_MODEL_REGEX.match?(model_name.to_s)
|
196
351
|
|
197
352
|
def openai_error_message(json)
|
198
353
|
err = json&.dig("error")
|
@@ -248,7 +403,13 @@ module Boxcars
|
|
248
403
|
prompt: call_ctx[:prompt_object],
|
249
404
|
inputs: call_ctx[:inputs],
|
250
405
|
user_id: user_id,
|
251
|
-
conversation_for_api:
|
406
|
+
conversation_for_api: if api_req.key?(:input)
|
407
|
+
api_req[:input]
|
408
|
+
elsif call_ctx[:is_chat_model]
|
409
|
+
api_req[:messages]
|
410
|
+
else
|
411
|
+
api_req[:prompt]
|
412
|
+
end
|
252
413
|
},
|
253
414
|
response_data: response_data,
|
254
415
|
provider: :openai
|
@@ -8,6 +8,7 @@ module Boxcars
|
|
8
8
|
# A engine that uses PerplexityAI's API.
|
9
9
|
class Perplexityai < Engine
|
10
10
|
include UnifiedObservability
|
11
|
+
|
11
12
|
attr_reader :prompts, :perplexity_params, :model_kwargs, :batch_size
|
12
13
|
|
13
14
|
DEFAULT_PARAMS = { # Renamed from DEFAULT_PER_PARAMS for consistency
|
@@ -55,10 +56,12 @@ module Boxcars
|
|
55
56
|
messages_for_api = current_prompt_object.as_messages(inputs)[:messages]
|
56
57
|
# Perplexity expects a 'model' and 'messages' structure.
|
57
58
|
# Other params like temperature, max_tokens are top-level.
|
59
|
+
# Filter out parameters that Perplexity doesn't support
|
60
|
+
supported_params = filter_supported_params(current_params)
|
58
61
|
api_request_params = {
|
59
|
-
model:
|
62
|
+
model: supported_params[:model],
|
60
63
|
messages: messages_for_api
|
61
|
-
}.merge(
|
64
|
+
}.merge(supported_params.except(:model, :messages, :perplexity_api_key))
|
62
65
|
|
63
66
|
log_messages_debug(api_request_params[:messages]) if Boxcars.configuration.log_prompts && api_request_params[:messages]
|
64
67
|
|
@@ -115,17 +118,52 @@ module Boxcars
|
|
115
118
|
|
116
119
|
def run(question, **)
|
117
120
|
prompt = Prompt.new(template: question)
|
118
|
-
|
121
|
+
response = client(prompt:, inputs: {}, **)
|
122
|
+
# Extract the content from the response for the run method
|
123
|
+
answer = extract_answer(response)
|
119
124
|
Boxcars.debug("Answer: #{answer}", :cyan)
|
120
125
|
answer
|
121
126
|
end
|
122
127
|
|
128
|
+
# Extract answer content from the API response
|
129
|
+
def extract_answer(response)
|
130
|
+
if response.is_a?(Hash) && response["choices"]
|
131
|
+
response["choices"].map { |c| c.dig("message", "content") }.join("\n").strip
|
132
|
+
else
|
133
|
+
response.to_s
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
123
137
|
def default_params
|
124
138
|
@perplexity_params
|
125
139
|
end
|
126
140
|
|
141
|
+
# validate_response! method uses the base implementation
|
142
|
+
def validate_response!(response, must_haves: %w[choices])
|
143
|
+
super
|
144
|
+
end
|
145
|
+
|
127
146
|
private
|
128
147
|
|
148
|
+
# Filter out parameters that Perplexity doesn't support
|
149
|
+
def filter_supported_params(params)
|
150
|
+
# Perplexity supports these parameters based on their API documentation
|
151
|
+
supported_keys = %i[
|
152
|
+
model
|
153
|
+
messages
|
154
|
+
temperature
|
155
|
+
max_tokens
|
156
|
+
top_p
|
157
|
+
top_k
|
158
|
+
stream
|
159
|
+
presence_penalty
|
160
|
+
frequency_penalty
|
161
|
+
]
|
162
|
+
|
163
|
+
# Remove unsupported parameters like stop, response_format, etc.
|
164
|
+
params.select { |key, _| supported_keys.include?(key.to_sym) }
|
165
|
+
end
|
166
|
+
|
129
167
|
def log_messages_debug(messages)
|
130
168
|
return unless messages.is_a?(Array)
|
131
169
|
|
@@ -141,10 +179,14 @@ module Boxcars
|
|
141
179
|
msg = err_details ? "#{err_details['type']}: #{err_details['message']}" : "Unknown error from PerplexityAI API"
|
142
180
|
raise Error, msg
|
143
181
|
else
|
144
|
-
|
145
|
-
|
182
|
+
parsed_response = response_data[:parsed_json]
|
183
|
+
unless parsed_response["choices"].is_a?(Array) && !parsed_response["choices"].empty?
|
184
|
+
raise Error,
|
185
|
+
"PerplexityAI: No choices found in response"
|
186
|
+
end
|
146
187
|
|
147
|
-
|
188
|
+
# Return the full parsed JSON response (Hash) as expected by the base Engine class
|
189
|
+
parsed_response
|
148
190
|
end
|
149
191
|
end
|
150
192
|
|
data/lib/boxcars/engine.rb
CHANGED
@@ -70,13 +70,17 @@ module Boxcars
|
|
70
70
|
raise TypeError, "Expected Hash from client method, got #{api_response_hash.class}: #{api_response_hash.inspect}"
|
71
71
|
end
|
72
72
|
|
73
|
-
|
73
|
+
validate_response!(api_response_hash)
|
74
74
|
|
75
75
|
current_choices = api_response_hash["choices"]
|
76
76
|
if current_choices.is_a?(Array)
|
77
77
|
choices.concat(current_choices)
|
78
|
+
elsif api_response_hash["output"]
|
79
|
+
# Synthesize a choice from non-Chat providers (e.g., OpenAI Responses API for GPT-5)
|
80
|
+
synthesized_text = extract_answer(api_response_hash)
|
81
|
+
choices << { "message" => { "content" => synthesized_text }, "finish_reason" => "stop" }
|
78
82
|
else
|
79
|
-
Boxcars.logger&.warn "No 'choices' found in API response: #{api_response_hash.inspect}"
|
83
|
+
Boxcars.logger&.warn "No 'choices' or 'output' found in API response: #{api_response_hash.inspect}"
|
80
84
|
end
|
81
85
|
|
82
86
|
api_usage = api_response_hash["usage"]
|
@@ -108,6 +112,34 @@ module Boxcars
|
|
108
112
|
response["output"] || response.to_s
|
109
113
|
end
|
110
114
|
end
|
115
|
+
|
116
|
+
# Validate API response and raise appropriate errors
|
117
|
+
# @param response [Hash] The response to validate.
|
118
|
+
# @param must_haves [Array<String>] The keys that must be in the response.
|
119
|
+
# @raise [KeyError] if there is an issue with the API key.
|
120
|
+
# @raise [Boxcars::Error] if the response is not valid.
|
121
|
+
def validate_response!(response, must_haves: %w[choices])
|
122
|
+
# Check for API errors first
|
123
|
+
if response['error']
|
124
|
+
error_details = response['error']
|
125
|
+
raise Boxcars::Error, "API error: #{error_details}" unless error_details.is_a?(Hash)
|
126
|
+
|
127
|
+
code = error_details['code']
|
128
|
+
message = error_details['message'] || 'unknown error'
|
129
|
+
|
130
|
+
# Handle common API key errors
|
131
|
+
raise KeyError, "API key not valid or permission denied" if ['invalid_api_key', 'permission_denied'].include?(code)
|
132
|
+
|
133
|
+
raise Boxcars::Error, "API error: #{message}"
|
134
|
+
|
135
|
+
end
|
136
|
+
|
137
|
+
# Check for required keys in response
|
138
|
+
has_required_content = must_haves.any? { |key| response.key?(key) && !response[key].nil? }
|
139
|
+
return if has_required_content
|
140
|
+
|
141
|
+
raise Boxcars::Error, "Response missing required keys. Expected one of: #{must_haves.join(', ')}"
|
142
|
+
end
|
111
143
|
end
|
112
144
|
end
|
113
145
|
|
data/lib/boxcars/engines.rb
CHANGED
@@ -4,7 +4,7 @@ module Boxcars
|
|
4
4
|
# Factory class for creating engine instances based on model names
|
5
5
|
# Provides convenient shortcuts and aliases for different AI models
|
6
6
|
class Engines
|
7
|
-
DEFAULT_MODEL = "gemini-2.5-flash
|
7
|
+
DEFAULT_MODEL = "gemini-2.5-flash"
|
8
8
|
|
9
9
|
# Create an engine instance based on the model name
|
10
10
|
# @param model [String] The model name or alias
|
@@ -36,9 +36,9 @@ module Boxcars
|
|
36
36
|
when "huge", "online_huge", "sonar-huge", "sonar-pro", "sonar_pro"
|
37
37
|
Boxcars::Perplexityai.new(model: "sonar-pro", **kw_args)
|
38
38
|
when "flash", "gemini-flash"
|
39
|
-
Boxcars::GeminiAi.new(model: "gemini-2.5-flash
|
39
|
+
Boxcars::GeminiAi.new(model: "gemini-2.5-flash", **kw_args)
|
40
40
|
when "gemini-pro"
|
41
|
-
Boxcars::GeminiAi.new(model: "gemini-2.5-pro
|
41
|
+
Boxcars::GeminiAi.new(model: "gemini-2.5-pro", **kw_args)
|
42
42
|
when /gemini-/
|
43
43
|
Boxcars::GeminiAi.new(model:, **kw_args)
|
44
44
|
when /-sonar-/
|
@@ -59,8 +59,11 @@ module Boxcars
|
|
59
59
|
# @param kw_args [Hash] Additional arguments to pass to the engine
|
60
60
|
# @return [Boxcars::Engine] An instance of the appropriate engine class
|
61
61
|
def self.json_engine(model: nil, **kw_args)
|
62
|
-
|
63
|
-
|
62
|
+
default_options = { temperature: 0.1 }
|
63
|
+
name = model.to_s
|
64
|
+
blocked = name.start_with?("gpt-5", "llama") || name.match?(/sonnet|opus|sonar/)
|
65
|
+
default_options[:response_format] = { type: "json_object" } unless blocked
|
66
|
+
options = default_options.merge(kw_args)
|
64
67
|
engine(model:, **options)
|
65
68
|
end
|
66
69
|
|
data/lib/boxcars/version.rb
CHANGED