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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -3
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +41 -0
  5. data/Gemfile +3 -13
  6. data/Gemfile.lock +29 -25
  7. data/POSTHOG_TEST_README.md +118 -0
  8. data/README.md +305 -0
  9. data/boxcars.gemspec +1 -2
  10. data/lib/boxcars/boxcar/active_record.rb +9 -10
  11. data/lib/boxcars/boxcar/calculator.rb +2 -2
  12. data/lib/boxcars/boxcar/engine_boxcar.rb +4 -4
  13. data/lib/boxcars/boxcar/google_search.rb +2 -2
  14. data/lib/boxcars/boxcar/json_engine_boxcar.rb +1 -1
  15. data/lib/boxcars/boxcar/ruby_calculator.rb +1 -1
  16. data/lib/boxcars/boxcar/sql_base.rb +4 -4
  17. data/lib/boxcars/boxcar/swagger.rb +3 -3
  18. data/lib/boxcars/boxcar/vector_answer.rb +3 -3
  19. data/lib/boxcars/boxcar/xml_engine_boxcar.rb +1 -1
  20. data/lib/boxcars/boxcar.rb +6 -6
  21. data/lib/boxcars/conversation_prompt.rb +3 -3
  22. data/lib/boxcars/engine/anthropic.rb +121 -23
  23. data/lib/boxcars/engine/cerebras.rb +2 -2
  24. data/lib/boxcars/engine/cohere.rb +135 -9
  25. data/lib/boxcars/engine/gemini_ai.rb +151 -76
  26. data/lib/boxcars/engine/google.rb +2 -2
  27. data/lib/boxcars/engine/gpt4all_eng.rb +92 -34
  28. data/lib/boxcars/engine/groq.rb +124 -73
  29. data/lib/boxcars/engine/intelligence_base.rb +52 -17
  30. data/lib/boxcars/engine/ollama.rb +127 -47
  31. data/lib/boxcars/engine/openai.rb +186 -103
  32. data/lib/boxcars/engine/perplexityai.rb +116 -136
  33. data/lib/boxcars/engine/together.rb +2 -2
  34. data/lib/boxcars/engine/unified_observability.rb +430 -0
  35. data/lib/boxcars/engine.rb +4 -3
  36. data/lib/boxcars/engines.rb +74 -0
  37. data/lib/boxcars/observability.rb +44 -0
  38. data/lib/boxcars/observability_backend.rb +17 -0
  39. data/lib/boxcars/observability_backends/multi_backend.rb +42 -0
  40. data/lib/boxcars/observability_backends/posthog_backend.rb +89 -0
  41. data/lib/boxcars/observation.rb +8 -8
  42. data/lib/boxcars/prompt.rb +16 -4
  43. data/lib/boxcars/result.rb +7 -12
  44. data/lib/boxcars/ruby_repl.rb +1 -1
  45. data/lib/boxcars/train/train_action.rb +1 -1
  46. data/lib/boxcars/train/xml_train.rb +3 -3
  47. data/lib/boxcars/train/xml_zero_shot.rb +1 -1
  48. data/lib/boxcars/train/zero_shot.rb +3 -3
  49. data/lib/boxcars/train.rb +1 -1
  50. data/lib/boxcars/vector_search.rb +5 -5
  51. data/lib/boxcars/vector_store/pgvector/build_from_array.rb +116 -88
  52. data/lib/boxcars/vector_store/pgvector/build_from_files.rb +106 -80
  53. data/lib/boxcars/vector_store/pgvector/save_to_database.rb +148 -122
  54. data/lib/boxcars/vector_store/pgvector/search.rb +157 -131
  55. data/lib/boxcars/vector_store.rb +4 -4
  56. data/lib/boxcars/version.rb +1 -1
  57. data/lib/boxcars.rb +31 -20
  58. 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: description, name: name)
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
- model_params = llm_params.merge(kwargs)
50
- api_key = Boxcars.configuration.anthropic_api_key(**kwargs)
51
- aclient = anthropic_client(anthropic_api_key: api_key)
52
- prompt = prompt.first if prompt.is_a?(Array)
53
- params = convert_to_anthropic(prompt.as_messages(inputs).merge(model_params))
54
- if Boxcars.configuration.log_prompts
55
- if params[:messages].length < 2 && params[:system].present?
56
- Boxcars.debug(">>>>>> Role: system <<<<<<\n#{params[:system]}")
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
- Boxcars.debug(params[:messages].last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
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
- response = aclient.messages(parameters: params)
61
- response['completion'] = response.dig('content', 0, 'text')
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, **kwargs)
92
+ def run(question, **)
74
93
  prompt = Prompt.new(template: question)
75
- response = client(prompt: prompt, **kwargs)
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: inputs, **params)
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: generations, engine_output: { token_usage: {} })
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].blank?
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, **kwargs)
25
- super(provider: :cerebras, description: description, name: name, prompts: prompts, batch_size: batch_size, **kwargs)
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: description, name: name)
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
- api_key = Boxcars.configuration.cohere_api_key(**kwargs)
63
- params = prompt.as_prompt(inputs: inputs, prefixes: default_prefixes, show_roles: true).merge(llm_params.merge(kwargs))
64
- params[:message] = params.delete(:prompt)
65
- params[:stop_sequences] = params.delete(:stop) if params.key?(:stop)
66
- Boxcars.debug("Prompt after formatting:#{params[:message]}", :cyan) if Boxcars.configuration.log_prompts
67
- chat(params, api_key)
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, **kwargs)
99
+ def run(question, **)
74
100
  prompt = Prompt.new(template: question)
75
- response = client(prompt: prompt, **kwargs)
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
- # Boxcars is a framework for running a series of tools to get an answer to a question.
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
- attr_reader :prompts, :llm_parmas, :model_kwargs, :batch_size
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
- # the default description of the engine
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
- @llm_parmas = DEFAULT_PARAMS.merge(kwargs)
22
+ @llm_params = DEFAULT_PARAMS.merge(kwargs) # Corrected typo here
29
23
  @prompts = prompts
30
24
  @batch_size = batch_size
31
- super(description: description, name: name)
25
+ super(description:, name:)
32
26
  end
33
27
 
34
- # Get the OpenAI API client
35
- # @param gemini_api_key [String] The access token to use when asking the engine.
36
- # Defaults to Boxcars.configuration.gemini_api_key
37
- # @return [OpenAI::Client] The OpenAI API gem client.
38
- def self.open_ai_client(gemini_api_key: nil)
39
- access_token = Boxcars.configuration.gemini_api_key(gemini_api_key: gemini_api_key)
40
- ::OpenAI::Client.new(access_token: access_token, uri_base: "https://generativelanguage.googleapis.com/v1beta/openai/")
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
- def conversation_model?(_model)
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
- clnt = GeminiAi.open_ai_client(gemini_api_key: gemini_api_key)
54
- params = llm_parmas.merge(kwargs)
55
- prompt = prompt.first if prompt.is_a?(Array)
56
- params = prompt.as_messages(inputs).merge(params)
57
- if Boxcars.configuration.log_prompts
58
- Boxcars.debug(params[:messages].last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
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
- clnt.chat(parameters: params)
61
- rescue => e
62
- Boxcars.error(e, :red)
63
- raise
81
+
82
+ _gemini_handle_call_outcome(response_data:)
64
83
  end
65
84
 
66
- # get an answer from the engine for a question.
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
- response = client(prompt: prompt, **kwargs)
72
- raise Error, "GeminiAI: No response from API" unless response
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
- # make sure we got a valid response
84
- # @param response [Hash] The response to check.
85
- # @param must_haves [Array<String>] The keys that must be in the response. Defaults to %w[choices].
86
- # @raise [KeyError] if there is an issue with the access token.
87
- # @raise [ValueError] if the response is not valid.
88
- def check_response(response, must_haves: %w[choices])
89
- if response['error'].is_a?(Hash)
90
- code = response.dig('error', 'code')
91
- msg = response.dig('error', 'message') || 'unknown error'
92
- raise KeyError, "GEMINI_API_TOKEN not valid" if code == 'invalid_api_key'
93
-
94
- raise ValueError, "GeminiAI error: #{msg}"
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
- must_haves.each do |key|
98
- raise ValueError, "Expecting key #{key} in response" unless response.key?(key)
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
- # the engine type
103
- def engine_type
104
- "gemini_ai"
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
- # Calculate the maximum number of tokens possible to generate for a prompt.
108
- # @param prompt_text [String] The prompt text to use.
109
- # @return [Integer] the number of tokens possible to generate.
110
- def max_tokens_for_prompt(prompt_text)
111
- num_tokens = get_num_tokens(prompt_text)
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
- # get max context size for model by name
114
- max_size = 8096
115
- max_size - num_tokens
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