boxcars 0.7.6 → 0.8.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/.rubocop.yml +6 -3
- data/.ruby-version +1 -1
- data/CHANGELOG.md +41 -0
- data/Gemfile +3 -13
- data/Gemfile.lock +29 -25
- data/POSTHOG_TEST_README.md +118 -0
- data/README.md +305 -0
- data/boxcars.gemspec +1 -2
- data/lib/boxcars/boxcar/active_record.rb +9 -10
- data/lib/boxcars/boxcar/calculator.rb +2 -2
- data/lib/boxcars/boxcar/engine_boxcar.rb +4 -4
- data/lib/boxcars/boxcar/google_search.rb +2 -2
- data/lib/boxcars/boxcar/json_engine_boxcar.rb +1 -1
- data/lib/boxcars/boxcar/ruby_calculator.rb +1 -1
- data/lib/boxcars/boxcar/sql_base.rb +4 -4
- data/lib/boxcars/boxcar/swagger.rb +3 -3
- data/lib/boxcars/boxcar/vector_answer.rb +3 -3
- data/lib/boxcars/boxcar/xml_engine_boxcar.rb +1 -1
- data/lib/boxcars/boxcar.rb +6 -6
- data/lib/boxcars/conversation_prompt.rb +3 -3
- data/lib/boxcars/engine/anthropic.rb +121 -23
- data/lib/boxcars/engine/cerebras.rb +2 -2
- data/lib/boxcars/engine/cohere.rb +135 -9
- data/lib/boxcars/engine/gemini_ai.rb +151 -76
- data/lib/boxcars/engine/google.rb +2 -2
- data/lib/boxcars/engine/gpt4all_eng.rb +92 -34
- data/lib/boxcars/engine/groq.rb +124 -73
- data/lib/boxcars/engine/intelligence_base.rb +52 -17
- data/lib/boxcars/engine/ollama.rb +127 -47
- data/lib/boxcars/engine/openai.rb +186 -103
- data/lib/boxcars/engine/perplexityai.rb +116 -136
- data/lib/boxcars/engine/together.rb +2 -2
- data/lib/boxcars/engine/unified_observability.rb +430 -0
- data/lib/boxcars/engine.rb +4 -3
- data/lib/boxcars/engines.rb +74 -0
- data/lib/boxcars/observability.rb +44 -0
- data/lib/boxcars/observability_backend.rb +17 -0
- data/lib/boxcars/observability_backends/multi_backend.rb +42 -0
- data/lib/boxcars/observability_backends/posthog_backend.rb +89 -0
- data/lib/boxcars/observation.rb +8 -8
- data/lib/boxcars/prompt.rb +16 -4
- data/lib/boxcars/result.rb +7 -12
- data/lib/boxcars/ruby_repl.rb +1 -1
- data/lib/boxcars/train/train_action.rb +1 -1
- data/lib/boxcars/train/xml_train.rb +3 -3
- data/lib/boxcars/train/xml_zero_shot.rb +1 -1
- data/lib/boxcars/train/zero_shot.rb +3 -3
- data/lib/boxcars/train.rb +1 -1
- data/lib/boxcars/vector_search.rb +5 -5
- data/lib/boxcars/vector_store/pgvector/build_from_array.rb +116 -88
- data/lib/boxcars/vector_store/pgvector/build_from_files.rb +106 -80
- data/lib/boxcars/vector_store/pgvector/save_to_database.rb +148 -122
- data/lib/boxcars/vector_store/pgvector/search.rb +157 -131
- data/lib/boxcars/vector_store.rb +4 -4
- data/lib/boxcars/version.rb +1 -1
- data/lib/boxcars.rb +31 -20
- metadata +11 -21
@@ -4,7 +4,9 @@ require 'anthropic'
|
|
4
4
|
# Boxcars is a framework for running a series of tools to get an answer to a question.
|
5
5
|
module Boxcars
|
6
6
|
# A engine that uses OpenAI's API.
|
7
|
+
# rubocop:disable Metrics/ClassLength
|
7
8
|
class Anthropic < Engine
|
9
|
+
include UnifiedObservability
|
8
10
|
attr_reader :prompts, :llm_params, :model_kwargs, :batch_size
|
9
11
|
|
10
12
|
# The default parameters to use when asking the engine.
|
@@ -29,7 +31,7 @@ module Boxcars
|
|
29
31
|
@llm_params = DEFAULT_PARAMS.merge(kwargs)
|
30
32
|
@prompts = prompts
|
31
33
|
@batch_size = 20
|
32
|
-
super(description
|
34
|
+
super(description:, name:)
|
33
35
|
end
|
34
36
|
|
35
37
|
def conversation_model?(_model)
|
@@ -46,33 +48,50 @@ module Boxcars
|
|
46
48
|
# Defaults to Boxcars.configuration.anthropic_api_key.
|
47
49
|
# @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
|
48
50
|
def client(prompt:, inputs: {}, **kwargs)
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
51
|
+
start_time = Time.now
|
52
|
+
response_data = { response_obj: nil, parsed_json: nil, success: false, error: nil, status_code: nil }
|
53
|
+
current_params = llm_params.merge(kwargs)
|
54
|
+
current_prompt_object = prompt.is_a?(Array) ? prompt.first : prompt
|
55
|
+
api_request_params = nil
|
56
|
+
|
57
|
+
begin
|
58
|
+
api_key = Boxcars.configuration.anthropic_api_key(**kwargs)
|
59
|
+
aclient = anthropic_client(anthropic_api_key: api_key)
|
60
|
+
api_request_params = convert_to_anthropic(current_prompt_object.as_messages(inputs).merge(current_params))
|
61
|
+
|
62
|
+
if Boxcars.configuration.log_prompts
|
63
|
+
if api_request_params[:messages].length < 2 && api_request_params[:system] && !api_request_params[:system].empty?
|
64
|
+
Boxcars.debug(">>>>>> Role: system <<<<<<\n#{api_request_params[:system]}")
|
65
|
+
end
|
66
|
+
Boxcars.debug(api_request_params[:messages].last(2).map do |p|
|
67
|
+
">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}"
|
68
|
+
end.join("\n"), :cyan)
|
57
69
|
end
|
58
|
-
|
70
|
+
|
71
|
+
raw_response = aclient.messages(parameters: api_request_params)
|
72
|
+
_process_anthropic_response(raw_response, response_data)
|
73
|
+
rescue StandardError => e
|
74
|
+
_handle_anthropic_error(e, response_data)
|
75
|
+
ensure
|
76
|
+
call_context = {
|
77
|
+
start_time:,
|
78
|
+
prompt_object: current_prompt_object,
|
79
|
+
inputs:,
|
80
|
+
api_request_params:,
|
81
|
+
current_params:
|
82
|
+
}
|
83
|
+
_track_anthropic_observability(call_context, response_data)
|
59
84
|
end
|
60
|
-
|
61
|
-
|
62
|
-
response.delete('content')
|
63
|
-
response
|
64
|
-
rescue StandardError => e
|
65
|
-
err = e.respond_to?(:response) ? e.response[:body] : e
|
66
|
-
Boxcars.warn("Anthropic Error #{e.class.name}: #{err}", :red)
|
67
|
-
raise
|
85
|
+
|
86
|
+
_anthropic_handle_call_outcome(response_data:)
|
68
87
|
end
|
69
88
|
|
70
89
|
# get an answer from the engine for a question.
|
71
90
|
# @param question [String] The question to ask the engine.
|
72
91
|
# @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
|
73
|
-
def run(question, **
|
92
|
+
def run(question, **)
|
74
93
|
prompt = Prompt.new(template: question)
|
75
|
-
response = client(prompt
|
94
|
+
response = client(prompt:, **)
|
76
95
|
|
77
96
|
raise Error, "Anthropic: No response from API" unless response
|
78
97
|
raise Error, "Anthropic: #{response['error']}" if response['error']
|
@@ -134,7 +153,7 @@ module Boxcars
|
|
134
153
|
# Includes prompt, completion, and total tokens used.
|
135
154
|
prompts.each_slice(batch_size) do |sub_prompts|
|
136
155
|
sub_prompts.each do |sprompts, inputs|
|
137
|
-
response = client(prompt: sprompts, inputs
|
156
|
+
response = client(prompt: sprompts, inputs:, **params)
|
138
157
|
check_response(response)
|
139
158
|
choices << response
|
140
159
|
end
|
@@ -146,7 +165,7 @@ module Boxcars
|
|
146
165
|
sub_choices = choices[i * n, (i + 1) * n]
|
147
166
|
generations.push(generation_info(sub_choices))
|
148
167
|
end
|
149
|
-
EngineResult.new(generations
|
168
|
+
EngineResult.new(generations:, engine_output: { token_usage: {} })
|
150
169
|
end
|
151
170
|
# rubocop:enable Metrics/AbcSize
|
152
171
|
|
@@ -191,12 +210,14 @@ module Boxcars
|
|
191
210
|
end
|
192
211
|
|
193
212
|
# convert generic parameters to Anthopic specific ones
|
213
|
+
# rubocop:disable Metrics/AbcSize
|
194
214
|
def convert_to_anthropic(params)
|
195
215
|
params[:stop_sequences] = params.delete(:stop) if params.key?(:stop)
|
196
216
|
params[:system] = params[:messages].shift[:content] if params.dig(:messages, 0, :role) == :system
|
197
|
-
params[:messages].pop if params[:messages].last[:content].
|
217
|
+
params[:messages].pop if params[:messages].last[:content].nil? || params[:messages].last[:content].strip.empty?
|
198
218
|
combine_assistant(params)
|
199
219
|
end
|
220
|
+
# rubocop:enable Metrics/AbcSize
|
200
221
|
|
201
222
|
def combine_assistant(params)
|
202
223
|
params[:messages] = combine_assistant_entries(params[:messages])
|
@@ -220,5 +241,82 @@ module Boxcars
|
|
220
241
|
def default_prefixes
|
221
242
|
{ system: "Human: ", user: "Human: ", assistant: "Assistant: ", history: :history }
|
222
243
|
end
|
244
|
+
|
245
|
+
private
|
246
|
+
|
247
|
+
# Process the raw response from Anthropic API
|
248
|
+
# rubocop:disable Metrics/AbcSize
|
249
|
+
def _process_anthropic_response(raw_response, response_data)
|
250
|
+
response_data[:response_obj] = raw_response
|
251
|
+
response_data[:parsed_json] = raw_response # Already parsed by Anthropic gem
|
252
|
+
|
253
|
+
if raw_response && !raw_response["error"]
|
254
|
+
response_data[:success] = true
|
255
|
+
response_data[:status_code] = 200 # Inferred
|
256
|
+
# Transform response to match expected format
|
257
|
+
raw_response['completion'] = raw_response.dig('content', 0, 'text')
|
258
|
+
raw_response.delete('content')
|
259
|
+
else
|
260
|
+
response_data[:success] = false
|
261
|
+
err_details = raw_response["error"] if raw_response
|
262
|
+
msg = err_details ? "#{err_details['type']}: #{err_details['message']}" : "Unknown Anthropic API Error"
|
263
|
+
response_data[:error] ||= StandardError.new(msg)
|
264
|
+
end
|
265
|
+
end
|
266
|
+
# rubocop:enable Metrics/AbcSize
|
267
|
+
|
268
|
+
# Handle errors from Anthropic API calls
|
269
|
+
def _handle_anthropic_error(error, response_data)
|
270
|
+
response_data[:error] = error
|
271
|
+
response_data[:success] = false
|
272
|
+
response_data[:status_code] = error.respond_to?(:http_status) ? error.http_status : nil
|
273
|
+
end
|
274
|
+
|
275
|
+
# Track observability using the unified system
|
276
|
+
def _track_anthropic_observability(call_context, response_data)
|
277
|
+
duration_ms = ((Time.now - call_context[:start_time]) * 1000).round
|
278
|
+
request_context = {
|
279
|
+
prompt: call_context[:prompt_object],
|
280
|
+
inputs: call_context[:inputs],
|
281
|
+
conversation_for_api: call_context[:api_request_params]
|
282
|
+
}
|
283
|
+
|
284
|
+
track_ai_generation(
|
285
|
+
duration_ms:,
|
286
|
+
current_params: call_context[:current_params],
|
287
|
+
request_context:,
|
288
|
+
response_data:,
|
289
|
+
provider: :anthropic
|
290
|
+
)
|
291
|
+
end
|
292
|
+
|
293
|
+
# Handle the final outcome of the API call
|
294
|
+
def _anthropic_handle_call_outcome(response_data:)
|
295
|
+
if response_data[:error]
|
296
|
+
_handle_anthropic_error_outcome(response_data[:error])
|
297
|
+
elsif !response_data[:success]
|
298
|
+
_handle_anthropic_response_body_error(response_data[:response_obj])
|
299
|
+
else
|
300
|
+
response_data[:parsed_json] # Return the raw parsed JSON
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
# Handle error outcomes
|
305
|
+
def _handle_anthropic_error_outcome(error_data)
|
306
|
+
detailed_error_message = error_data.message
|
307
|
+
if error_data.respond_to?(:response) && error_data.response
|
308
|
+
detailed_error_message += " - Details: #{error_data.response[:body]}"
|
309
|
+
end
|
310
|
+
Boxcars.error("Anthropic Error: #{detailed_error_message} (#{error_data.class.name})", :red)
|
311
|
+
raise error_data
|
312
|
+
end
|
313
|
+
|
314
|
+
# Handle response body errors
|
315
|
+
def _handle_anthropic_response_body_error(response_obj)
|
316
|
+
err_details = response_obj&.dig("error")
|
317
|
+
msg = err_details ? "#{err_details['type']}: #{err_details['message']}" : "Unknown error from Anthropic API"
|
318
|
+
raise Error, msg
|
319
|
+
end
|
223
320
|
end
|
321
|
+
# rubocop:enable Metrics/ClassLength
|
224
322
|
end
|
@@ -21,8 +21,8 @@ module Boxcars
|
|
21
21
|
# @param prompts [Array<Prompt>] The prompts to use for the Engine.
|
22
22
|
# @param batch_size [Integer] The number of prompts to send to the Engine at a time.
|
23
23
|
# @param kwargs [Hash] Additional parameters to pass to the Engine.
|
24
|
-
def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **
|
25
|
-
super(provider: :cerebras, description
|
24
|
+
def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **)
|
25
|
+
super(provider: :cerebras, description:, name:, prompts:, batch_size:, **)
|
26
26
|
end
|
27
27
|
|
28
28
|
def default_model_params
|
@@ -4,6 +4,7 @@
|
|
4
4
|
module Boxcars
|
5
5
|
# A engine that uses Cohere's API.
|
6
6
|
class Cohere < Engine
|
7
|
+
include UnifiedObservability
|
7
8
|
attr_reader :prompts, :llm_params, :model_kwargs, :batch_size
|
8
9
|
|
9
10
|
# The default parameters to use when asking the engine.
|
@@ -29,7 +30,7 @@ module Boxcars
|
|
29
30
|
@llm_params = DEFAULT_PARAMS.merge(kwargs)
|
30
31
|
@prompts = prompts
|
31
32
|
@batch_size = 20
|
32
|
-
super(description
|
33
|
+
super(description:, name:)
|
33
34
|
end
|
34
35
|
|
35
36
|
def conversation_model?(_model)
|
@@ -59,20 +60,45 @@ module Boxcars
|
|
59
60
|
# Defaults to Boxcars.configuration.cohere_api_key.
|
60
61
|
# @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
|
61
62
|
def client(prompt:, inputs: {}, **kwargs)
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
63
|
+
start_time = Time.now
|
64
|
+
response_data = { response_obj: nil, parsed_json: nil, success: false, error: nil, status_code: nil }
|
65
|
+
current_params = llm_params.merge(kwargs)
|
66
|
+
current_prompt_object = prompt.is_a?(Array) ? prompt.first : prompt
|
67
|
+
api_request_params = nil
|
68
|
+
|
69
|
+
begin
|
70
|
+
api_key = Boxcars.configuration.cohere_api_key(**kwargs)
|
71
|
+
api_request_params = current_prompt_object.as_prompt(inputs:, prefixes: default_prefixes,
|
72
|
+
show_roles: true).merge(current_params)
|
73
|
+
api_request_params[:message] = api_request_params.delete(:prompt)
|
74
|
+
api_request_params[:stop_sequences] = api_request_params.delete(:stop) if api_request_params.key?(:stop)
|
75
|
+
|
76
|
+
Boxcars.debug("Prompt after formatting:#{api_request_params[:message]}", :cyan) if Boxcars.configuration.log_prompts
|
77
|
+
|
78
|
+
raw_response = _cohere_api_call(api_request_params, api_key)
|
79
|
+
_process_cohere_response(raw_response, response_data)
|
80
|
+
rescue StandardError => e
|
81
|
+
_handle_cohere_error(e, response_data)
|
82
|
+
ensure
|
83
|
+
call_context = {
|
84
|
+
start_time:,
|
85
|
+
prompt_object: current_prompt_object,
|
86
|
+
inputs:,
|
87
|
+
api_request_params:,
|
88
|
+
current_params:
|
89
|
+
}
|
90
|
+
_track_cohere_observability(call_context, response_data)
|
91
|
+
end
|
92
|
+
|
93
|
+
_cohere_handle_call_outcome(response_data:)
|
68
94
|
end
|
69
95
|
|
70
96
|
# get an answer from the engine for a question.
|
71
97
|
# @param question [String] The question to ask the engine.
|
72
98
|
# @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
|
73
|
-
def run(question, **
|
99
|
+
def run(question, **)
|
74
100
|
prompt = Prompt.new(template: question)
|
75
|
-
response = client(prompt
|
101
|
+
response = client(prompt:, **)
|
76
102
|
|
77
103
|
raise Error, "Cohere: No response from API" unless response
|
78
104
|
raise Error, "Cohere: #{response[:error]}" if response[:error]
|
@@ -131,5 +157,105 @@ module Boxcars
|
|
131
157
|
def default_prefixes
|
132
158
|
{ system: "SYSTEM: ", user: "USER: ", assistant: "CHATBOT: ", history: :history }
|
133
159
|
end
|
160
|
+
|
161
|
+
private
|
162
|
+
|
163
|
+
# Make the actual API call to Cohere
|
164
|
+
def _cohere_api_call(params, api_key)
|
165
|
+
raise Boxcars::Error, 'Cohere API key not set' if api_key.blank?
|
166
|
+
|
167
|
+
# Define the API endpoint and parameters
|
168
|
+
api_endpoint = 'https://api.cohere.ai/v1/chat'
|
169
|
+
|
170
|
+
connection = Faraday.new(api_endpoint) do |faraday|
|
171
|
+
faraday.request :url_encoded
|
172
|
+
faraday.headers['Authorization'] = "Bearer #{api_key}"
|
173
|
+
faraday.headers['Content-Type'] = 'application/json'
|
174
|
+
end
|
175
|
+
|
176
|
+
# Make the API call
|
177
|
+
connection.post { |req| req.body = params.to_json }
|
178
|
+
end
|
179
|
+
|
180
|
+
# Process the raw response from Cohere API
|
181
|
+
def _process_cohere_response(raw_response, response_data)
|
182
|
+
response_data[:response_obj] = raw_response
|
183
|
+
response_data[:status_code] = raw_response.status
|
184
|
+
|
185
|
+
if raw_response.status == 200
|
186
|
+
parsed_json = JSON.parse(raw_response.body, symbolize_names: true)
|
187
|
+
response_data[:parsed_json] = parsed_json
|
188
|
+
|
189
|
+
if parsed_json[:error]
|
190
|
+
response_data[:success] = false
|
191
|
+
response_data[:error] = Boxcars::Error.new("Cohere API Error: #{parsed_json[:error]}")
|
192
|
+
else
|
193
|
+
response_data[:success] = true
|
194
|
+
end
|
195
|
+
else
|
196
|
+
response_data[:success] = false
|
197
|
+
response_data[:error] = Boxcars::Error.new("HTTP #{raw_response.status}: #{raw_response.reason_phrase}")
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
# Handle errors from Cohere API calls
|
202
|
+
def _handle_cohere_error(error, response_data)
|
203
|
+
response_data[:error] = error
|
204
|
+
response_data[:success] = false
|
205
|
+
response_data[:status_code] = error.respond_to?(:response) && error.response ? error.response[:status] : nil
|
206
|
+
end
|
207
|
+
|
208
|
+
# Track observability using the unified system
|
209
|
+
def _track_cohere_observability(call_context, response_data)
|
210
|
+
duration_ms = ((Time.now - call_context[:start_time]) * 1000).round
|
211
|
+
request_context = {
|
212
|
+
prompt: call_context[:prompt_object],
|
213
|
+
inputs: call_context[:inputs],
|
214
|
+
conversation_for_api: call_context[:api_request_params]
|
215
|
+
}
|
216
|
+
|
217
|
+
track_ai_generation(
|
218
|
+
duration_ms:,
|
219
|
+
current_params: call_context[:current_params],
|
220
|
+
request_context:,
|
221
|
+
response_data:,
|
222
|
+
provider: :cohere
|
223
|
+
)
|
224
|
+
end
|
225
|
+
|
226
|
+
# Handle the final outcome of the API call
|
227
|
+
def _cohere_handle_call_outcome(response_data:)
|
228
|
+
if response_data[:error]
|
229
|
+
_handle_cohere_error_outcome(response_data[:error])
|
230
|
+
elsif !response_data[:success]
|
231
|
+
_handle_cohere_response_body_error(response_data[:response_obj])
|
232
|
+
else
|
233
|
+
response_data[:parsed_json] # Return the raw parsed JSON
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
# Handle error outcomes
|
238
|
+
def _handle_cohere_error_outcome(error_data)
|
239
|
+
detailed_error_message = error_data.message
|
240
|
+
if error_data.respond_to?(:response) && error_data.response
|
241
|
+
detailed_error_message += " - Details: #{error_data.response[:body]}"
|
242
|
+
end
|
243
|
+
Boxcars.error("Cohere Error: #{detailed_error_message} (#{error_data.class.name})", :red)
|
244
|
+
raise error_data
|
245
|
+
end
|
246
|
+
|
247
|
+
# Handle response body errors
|
248
|
+
def _handle_cohere_response_body_error(response_obj)
|
249
|
+
msg = "Unknown error from Cohere API"
|
250
|
+
if response_obj.respond_to?(:body)
|
251
|
+
begin
|
252
|
+
parsed_body = JSON.parse(response_obj.body)
|
253
|
+
msg = parsed_body["message"] || parsed_body["error"] || msg
|
254
|
+
rescue JSON::ParserError
|
255
|
+
msg = "HTTP #{response_obj.status}: #{response_obj.reason_phrase}"
|
256
|
+
end
|
257
|
+
end
|
258
|
+
raise Error, msg
|
259
|
+
end
|
134
260
|
end
|
135
261
|
end
|
@@ -1,118 +1,193 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
#
|
3
|
+
require 'openai' # Gemini uses the OpenAI gem with a custom URI base
|
4
|
+
require 'json'
|
5
|
+
|
4
6
|
module Boxcars
|
5
|
-
# A engine that uses GeminiAI's API.
|
7
|
+
# A engine that uses GeminiAI's API via an OpenAI-compatible interface.
|
6
8
|
class GeminiAi < Engine
|
7
|
-
|
9
|
+
include UnifiedObservability
|
10
|
+
attr_reader :prompts, :llm_params, :model_kwargs, :batch_size # Corrected typo llm_parmas to llm_params
|
8
11
|
|
9
|
-
# The default parameters to use when asking the engine.
|
10
12
|
DEFAULT_PARAMS = {
|
11
|
-
model: "gemini-1.5-flash-latest",
|
13
|
+
model: "gemini-1.5-flash-latest", # Default model for Gemini
|
12
14
|
temperature: 0.1
|
15
|
+
# max_tokens is often part of the request, not a fixed default here
|
13
16
|
}.freeze
|
14
|
-
|
15
|
-
# the default name of the engine
|
16
17
|
DEFAULT_NAME = "GeminiAI engine"
|
17
|
-
|
18
|
-
DEFAULT_DESCRIPTION = "useful for when you need to use AI to answer questions. " \
|
18
|
+
DEFAULT_DESCRIPTION = "useful for when you need to use Gemini AI to answer questions. " \
|
19
19
|
"You should ask targeted questions"
|
20
20
|
|
21
|
-
# A engine is a container for a single tool to run.
|
22
|
-
# @param name [String] The name of the engine. Defaults to "GeminiAI engine".
|
23
|
-
# @param description [String] A description of the engine. Defaults to:
|
24
|
-
# useful for when you need to use AI to answer questions. You should ask targeted questions".
|
25
|
-
# @param prompts [Array<String>] The prompts to use when asking the engine. Defaults to [].
|
26
|
-
# @param batch_size [Integer] The number of prompts to send to the engine at once. Defaults to 20.
|
27
21
|
def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **kwargs)
|
28
|
-
@
|
22
|
+
@llm_params = DEFAULT_PARAMS.merge(kwargs) # Corrected typo here
|
29
23
|
@prompts = prompts
|
30
24
|
@batch_size = batch_size
|
31
|
-
super(description
|
25
|
+
super(description:, name:)
|
32
26
|
end
|
33
27
|
|
34
|
-
#
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
access_token
|
40
|
-
|
28
|
+
# Renamed from open_ai_client to gemini_client for clarity
|
29
|
+
def self.gemini_client(gemini_api_key: nil)
|
30
|
+
access_token = Boxcars.configuration.gemini_api_key(gemini_api_key:)
|
31
|
+
# NOTE: The OpenAI gem might not support `log_errors: true` for custom uri_base.
|
32
|
+
# It's a param for OpenAI::Client specific to their setup.
|
33
|
+
::OpenAI::Client.new(access_token:, uri_base: "https://generativelanguage.googleapis.com/v1beta/")
|
34
|
+
# Removed /openai from uri_base as it's usually for OpenAI-specific paths on custom domains.
|
35
|
+
# The Gemini endpoint might be directly at /v1beta/models/gemini...:generateContent
|
36
|
+
# This might need adjustment based on how the OpenAI gem forms the full URL.
|
37
|
+
# For direct generateContent, a different client or HTTP call might be needed if OpenAI gem is too restrictive.
|
38
|
+
# Assuming for now it's an OpenAI-compatible chat endpoint.
|
41
39
|
end
|
42
40
|
|
43
|
-
|
41
|
+
# Gemini models are typically conversational.
|
42
|
+
def conversation_model?(_model_name)
|
44
43
|
true
|
45
44
|
end
|
46
45
|
|
47
|
-
# Get an answer from the engine.
|
48
|
-
# @param prompt [String] The prompt to use when asking the engine.
|
49
|
-
# @param gemini_api_key [String] The access token to use when asking the engine.
|
50
|
-
# Defaults to Boxcars.configuration.gemini_api_key.
|
51
|
-
# @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
|
52
46
|
def client(prompt:, inputs: {}, gemini_api_key: nil, **kwargs)
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
47
|
+
start_time = Time.now
|
48
|
+
response_data = { response_obj: nil, parsed_json: nil, success: false, error: nil, status_code: nil }
|
49
|
+
current_params = @llm_params.merge(kwargs) # Use instance var @llm_params
|
50
|
+
api_request_params = nil
|
51
|
+
current_prompt_object = prompt.is_a?(Array) ? prompt.first : prompt
|
52
|
+
|
53
|
+
begin
|
54
|
+
clnt = GeminiAi.gemini_client(gemini_api_key:)
|
55
|
+
api_request_params = _prepare_gemini_request_params(current_prompt_object, inputs, current_params)
|
56
|
+
|
57
|
+
log_messages_debug(api_request_params[:messages]) if Boxcars.configuration.log_prompts && api_request_params[:messages]
|
58
|
+
_execute_and_process_gemini_call(clnt, api_request_params, response_data)
|
59
|
+
rescue ::OpenAI::Error => e # Catch OpenAI gem errors if they apply
|
60
|
+
response_data[:error] = e
|
61
|
+
response_data[:success] = false
|
62
|
+
response_data[:status_code] = e.http_status if e.respond_to?(:http_status)
|
63
|
+
rescue StandardError => e # Catch other errors
|
64
|
+
response_data[:error] = e
|
65
|
+
response_data[:success] = false
|
66
|
+
ensure
|
67
|
+
duration_ms = ((Time.now - start_time) * 1000).round
|
68
|
+
request_context = {
|
69
|
+
prompt: current_prompt_object,
|
70
|
+
inputs:,
|
71
|
+
conversation_for_api: api_request_params&.dig(:messages) || []
|
72
|
+
}
|
73
|
+
track_ai_generation(
|
74
|
+
duration_ms:,
|
75
|
+
current_params:,
|
76
|
+
request_context:,
|
77
|
+
response_data:,
|
78
|
+
provider: :gemini
|
79
|
+
)
|
59
80
|
end
|
60
|
-
|
61
|
-
|
62
|
-
Boxcars.error(e, :red)
|
63
|
-
raise
|
81
|
+
|
82
|
+
_gemini_handle_call_outcome(response_data:)
|
64
83
|
end
|
65
84
|
|
66
|
-
|
67
|
-
# @param question [String] The question to ask the engine.
|
68
|
-
# @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
|
69
|
-
def run(question, **kwargs)
|
85
|
+
def run(question, **)
|
70
86
|
prompt = Prompt.new(template: question)
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
check_response(response)
|
75
|
-
response["choices"].map { |c| c.dig("message", "content") || c["text"] }.join("\n").strip
|
87
|
+
answer = client(prompt:, inputs: {}, **)
|
88
|
+
Boxcars.debug("Answer: #{answer}", :cyan)
|
89
|
+
answer
|
76
90
|
end
|
77
91
|
|
78
|
-
# Get the default parameters for the engine.
|
79
92
|
def default_params
|
80
|
-
llm_params
|
93
|
+
@llm_params # Use instance variable
|
81
94
|
end
|
82
95
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
96
|
+
private
|
97
|
+
|
98
|
+
def _execute_and_process_gemini_call(gemini_client_obj, prepared_api_params, current_response_data)
|
99
|
+
# The OpenAI gem's `chat` method might not work directly if Gemini's endpoint
|
100
|
+
# isn't perfectly OpenAI-compatible for chat completions.
|
101
|
+
# It might require calling a different method or using a more direct HTTP client.
|
102
|
+
# For this refactor, we'll assume `gemini_client_obj.chat` is the intended path.
|
103
|
+
raw_response = gemini_client_obj.chat(parameters: prepared_api_params)
|
104
|
+
|
105
|
+
current_response_data[:response_obj] = raw_response
|
106
|
+
current_response_data[:parsed_json] = raw_response # OpenAI gem returns Hash
|
107
|
+
|
108
|
+
if raw_response && !raw_response["error"] &&
|
109
|
+
(raw_response["choices"] || raw_response["candidates"]) # Combined check for OpenAI or Gemini success
|
110
|
+
current_response_data[:success] = true
|
111
|
+
current_response_data[:status_code] = 200 # Inferred
|
112
|
+
else
|
113
|
+
current_response_data[:success] = false
|
114
|
+
err_details = raw_response["error"] if raw_response
|
115
|
+
msg = if err_details
|
116
|
+
(err_details.is_a?(Hash) ? err_details['message'] : err_details).to_s
|
117
|
+
else
|
118
|
+
"Unknown Gemini API Error"
|
119
|
+
end
|
120
|
+
current_response_data[:error] = StandardError.new(msg)
|
95
121
|
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def _prepare_gemini_request_params(current_prompt, current_inputs, current_engine_params)
|
125
|
+
# Gemini typically uses a chat-like interface.
|
126
|
+
# Prepare messages for the API
|
127
|
+
# current_prompt.as_messages(current_inputs) returns a hash like { messages: [...] }
|
128
|
+
# We need to extract the array part for the OpenAI client's :messages parameter.
|
129
|
+
message_hash = current_prompt.as_messages(current_inputs)
|
130
|
+
# Ensure roles are 'user' and 'model' for Gemini if needed, or transform them.
|
131
|
+
# OpenAI gem expects 'system', 'user', 'assistant'. Adapter logic might be needed.
|
132
|
+
# For now, assume as_messages produces compatible roles or Gemini endpoint handles them.
|
133
|
+
|
134
|
+
# Gemini might not use 'model' in the same way in request body if using generateContent directly.
|
135
|
+
# If using OpenAI gem's chat method, it expects 'model' for routing.
|
136
|
+
# Let's assume api_request_params are for OpenAI gem's chat method.
|
137
|
+
{ messages: message_hash[:messages] }.merge(current_engine_params)
|
138
|
+
end
|
139
|
+
|
140
|
+
def log_messages_debug(messages)
|
141
|
+
return unless messages.is_a?(Array)
|
142
|
+
|
143
|
+
Boxcars.debug(messages.last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
|
144
|
+
end
|
96
145
|
|
97
|
-
|
98
|
-
|
146
|
+
def _gemini_handle_call_outcome(response_data:)
|
147
|
+
if response_data[:error]
|
148
|
+
Boxcars.error("GeminiAI Error: #{response_data[:error].message} (#{response_data[:error].class.name})", :red)
|
149
|
+
raise response_data[:error]
|
150
|
+
elsif !response_data[:success]
|
151
|
+
err_details = response_data.dig(:response_obj, "error")
|
152
|
+
msg = if err_details
|
153
|
+
err_details.is_a?(Hash) ? "#{err_details['type']}: #{err_details['message']}" : err_details.to_s
|
154
|
+
else
|
155
|
+
"Unknown error from GeminiAI API"
|
156
|
+
end
|
157
|
+
raise Error, msg
|
158
|
+
else
|
159
|
+
_extract_content_from_gemini_response(response_data[:parsed_json])
|
99
160
|
end
|
100
161
|
end
|
101
162
|
|
102
|
-
|
103
|
-
|
104
|
-
|
163
|
+
def _extract_content_from_gemini_response(parsed_json)
|
164
|
+
# Handle Gemini's specific response structure (candidates)
|
165
|
+
# or OpenAI-compatible structure if the endpoint behaves that way.
|
166
|
+
if parsed_json&.key?("candidates") # Native Gemini generateContent response
|
167
|
+
parsed_json["candidates"].map { |c| c.dig("content", "parts", 0, "text") }.join("\n").strip
|
168
|
+
elsif parsed_json&.key?("choices") # OpenAI-compatible response
|
169
|
+
parsed_json["choices"].map { |c| c.dig("message", "content") || c["text"] }.join("\n").strip
|
170
|
+
else
|
171
|
+
raise Error, "GeminiAI: Could not extract answer from response"
|
172
|
+
end
|
105
173
|
end
|
106
174
|
|
107
|
-
#
|
108
|
-
#
|
109
|
-
|
110
|
-
|
111
|
-
|
175
|
+
# check_response method might be partially covered by _gemini_handle_call_outcome
|
176
|
+
# Retaining it if run method still uses it explicitly.
|
177
|
+
def check_response(response, must_haves: %w[choices candidates])
|
178
|
+
if response['error'].is_a?(Hash)
|
179
|
+
code = response.dig('error', 'code')
|
180
|
+
msg = response.dig('error', 'message') || 'unknown error'
|
181
|
+
# GEMINI_API_TOKEN is not standard, usually it's an API key.
|
182
|
+
# This check might need to align with actual error codes from Gemini.
|
183
|
+
raise KeyError, "Gemini API Key not valid or permission issue" if ['invalid_api_key', 'permission_denied'].include?(code)
|
184
|
+
|
185
|
+
raise ValueError, "GeminiAI error: #{msg}"
|
186
|
+
end
|
112
187
|
|
113
|
-
#
|
114
|
-
|
115
|
-
|
188
|
+
# Check for either 'choices' (OpenAI style) or 'candidates' (Gemini native style)
|
189
|
+
has_valid_content = must_haves.any? { |key| response.key?(key) && !response[key].empty? }
|
190
|
+
raise ValueError, "Expecting key like 'choices' or 'candidates' in response" unless has_valid_content
|
116
191
|
end
|
117
192
|
end
|
118
193
|
end
|