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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3c999092bd0fd0799ee3c97e74b0551e23fa5318eeb746fb17f52ed8b8e8e395
4
- data.tar.gz: 9e4c76ab93ad3cf305af160679b05a46d4b72fb04d0ee40298b7e362d387e582
3
+ metadata.gz: 8261b5fd1cf581d141ae39fd3d567abd9751a98c6227c3f56c3d221275eb08d1
4
+ data.tar.gz: 5bb1a1f94a6076fa9f72542aa7117bd981d7f5191781cea917822109e93ace6c
5
5
  SHA512:
6
- metadata.gz: 2d7a5e62a2f0de60d789c97801d3832b2bd5aee4ceb82be59a8c1f66d724e931819412dede12027019c7d98b46b2a90e22c17660ed03ecab7d822ffa8290d260
7
- data.tar.gz: a2b51cef624374b84506cb176a3ee5b5732db9fbd87ce4b824b803cf641a623c2e1bb9e22bcb65b7a230f261c913823f1fd65081ac6aa4e6c7068f620b82b63f
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.6)
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.1)
18
- activesupport (= 7.2.2.1)
19
- activerecord (7.2.2.1)
20
- activemodel (= 7.2.2.1)
21
- activesupport (= 7.2.2.1)
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.1)
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.0)
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.31.0)
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.0)
78
- erb (5.0.1)
77
+ dynamicschema (1.0.1)
78
+ erb (5.0.2)
79
79
  event_stream_parser (1.0.0)
80
- faraday (2.13.1)
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.0)
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.12.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.0701)
135
+ mime-types-data (3.2025.0819)
136
136
  minitest (5.25.5)
137
- multi_json (1.15.0)
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.8-aarch64-linux-gnu)
143
+ nokogiri (1.18.9-aarch64-linux-gnu)
144
144
  racc (~> 1.4)
145
- nokogiri (1.18.8-aarch64-linux-musl)
145
+ nokogiri (1.18.9-aarch64-linux-musl)
146
146
  racc (~> 1.4)
147
- nokogiri (1.18.8-arm-linux-gnu)
147
+ nokogiri (1.18.9-arm-linux-gnu)
148
148
  racc (~> 1.4)
149
- nokogiri (1.18.8-arm-linux-musl)
149
+ nokogiri (1.18.9-arm-linux-musl)
150
150
  racc (~> 1.4)
151
- nokogiri (1.18.8-arm64-darwin)
151
+ nokogiri (1.18.9-arm64-darwin)
152
152
  racc (~> 1.4)
153
- nokogiri (1.18.8-x86_64-darwin)
153
+ nokogiri (1.18.9-x86_64-darwin)
154
154
  racc (~> 1.4)
155
- nokogiri (1.18.8-x86_64-linux-gnu)
155
+ nokogiri (1.18.9-x86_64-linux-gnu)
156
156
  racc (~> 1.4)
157
- nokogiri (1.18.8-x86_64-linux-musl)
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.8.0)
164
+ parser (3.3.9.0)
165
165
  ast (~> 2.4.1)
166
166
  racc
167
- pg (1.5.9)
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.0.1)
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.1)
195
+ rdoc (6.14.2)
190
196
  erb
191
197
  psych (>= 4.0.0)
192
- regexp_parser (2.10.0)
193
- reline (0.6.1)
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.4)
214
- rubocop (1.77.0)
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.45.1, < 2.0)
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.45.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.1.0)
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.1-aarch64-linux-gnu)
247
- sqlite3 (2.7.1-aarch64-linux-musl)
248
- sqlite3 (2.7.1-arm-linux-gnu)
249
- sqlite3 (2.7.1-arm-linux-musl)
250
- sqlite3 (2.7.1-arm64-darwin)
251
- sqlite3 (2.7.1-x86_64-darwin)
252
- sqlite3 (2.7.1-x86_64-linux-gnu)
253
- sqlite3 (2.7.1-x86_64-linux-musl)
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.15.2)
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)
@@ -32,7 +32,7 @@ module Boxcars
32
32
 
33
33
  Output Format:
34
34
  {
35
- %<wanted_data>s
35
+ %<wanted_data>s
36
36
  }
37
37
  SYSPR
38
38
  stock_prompt += "\n\nImportant:\n#{important}\n" unless important.to_s.empty?
@@ -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
- # make sure we got a valid response
126
- # @param response [Hash] The response to check.
127
- # @param must_haves [Array<String>] The keys that must be in the response. Defaults to %w[choices].
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
- check_response(response)
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
- # make sure we got a valid response
118
- # @param response [Hash] The response to check.
119
- # @param must_haves [Array<String>] The keys that must be in the response. Defaults to %w[choices].
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
- # check_response method might be partially covered by _gemini_handle_call_outcome
182
- # Retaining it if run method still uses it explicitly.
183
- def check_response(response, must_haves: %w[choices candidates])
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
@@ -7,6 +7,7 @@ module Boxcars
7
7
  # A engine that uses local GPT4All API.
8
8
  class Gpt4allEng < Engine
9
9
  include UnifiedObservability
10
+
10
11
  attr_reader :prompts, :model_kwargs, :batch_size, :gpt4all_params # Added gpt4all_params
11
12
 
12
13
  DEFAULT_NAME = "Gpt4all engine"
@@ -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
- # Retaining check_response if run method or other parts still use it.
161
- def check_response(response, must_haves: %w[choices])
162
- if response['error'].is_a?(Hash)
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 check_response(response)
123
- return if response.is_a?(Hash) && response.key?("choices")
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
@@ -7,6 +7,7 @@ module Boxcars
7
7
  # A engine that uses a local Ollama API (OpenAI-compatible).
8
8
  class Ollama < Engine
9
9
  include UnifiedObservability
10
+
10
11
  attr_reader :prompts, :model_kwargs, :batch_size, :ollama_params
11
12
 
12
13
  DEFAULT_PARAMS = {
@@ -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
- extract_answer_from_choices(raw_json["choices"]).tap do |ans|
84
- Boxcars.debug("Answer: #{ans}", :cyan)
85
- end
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 `check_response` directly.
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 check_response(response) # rubocop:disable Naming/PredicateMethod
106
- if (msg = openai_error_message(response))
107
- raise Boxcars::Error, msg
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 chat
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 chat_mode
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: call_ctx[:is_chat_model] ? api_req[:messages] : api_req[:prompt]
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: current_params[:model],
62
+ model: supported_params[:model],
60
63
  messages: messages_for_api
61
- }.merge(current_params.except(:model, :messages, :perplexity_api_key)) # Add other relevant params
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
- answer = client(prompt:, inputs: {}, **)
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
- choices = response_data.dig(:parsed_json, "choices")
145
- raise Error, "PerplexityAI: No choices found in response" unless choices.is_a?(Array) && !choices.empty?
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
- choices.map { |c| c.dig("message", "content") }.join("\n").strip
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
 
@@ -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
- check_response(api_response_hash)
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
 
@@ -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-preview-05-20"
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-preview-05-20", **kw_args)
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-preview-05-06", **kw_args)
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
- options = { temperature: 0.1, response_format: { type: "json_object" } }.merge(kw_args)
63
- options.delete(:response_format) if model.to_s =~ /sonnet|opus|sonar/ || model.to_s.start_with?("llama")
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
 
@@ -4,6 +4,7 @@ module Boxcars
4
4
  module VectorStore
5
5
  class EmbedViaTensorflow
6
6
  include VectorStore
7
+
7
8
  def call
8
9
  raise NotImplementedError
9
10
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Boxcars
4
4
  # The current version of the gem.
5
- VERSION = "0.8.6"
5
+ VERSION = "0.8.8"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: boxcars
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.6
4
+ version: 0.8.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Francis Sullivan