boxcars 0.7.7 → 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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -3
  3. data/.ruby-version +1 -1
  4. data/Gemfile +3 -13
  5. data/Gemfile.lock +29 -25
  6. data/POSTHOG_TEST_README.md +118 -0
  7. data/README.md +305 -0
  8. data/boxcars.gemspec +1 -2
  9. data/lib/boxcars/boxcar/active_record.rb +9 -10
  10. data/lib/boxcars/boxcar/calculator.rb +2 -2
  11. data/lib/boxcars/boxcar/engine_boxcar.rb +4 -4
  12. data/lib/boxcars/boxcar/google_search.rb +2 -2
  13. data/lib/boxcars/boxcar/json_engine_boxcar.rb +1 -1
  14. data/lib/boxcars/boxcar/ruby_calculator.rb +1 -1
  15. data/lib/boxcars/boxcar/sql_base.rb +4 -4
  16. data/lib/boxcars/boxcar/swagger.rb +3 -3
  17. data/lib/boxcars/boxcar/vector_answer.rb +3 -3
  18. data/lib/boxcars/boxcar/xml_engine_boxcar.rb +1 -1
  19. data/lib/boxcars/boxcar.rb +6 -6
  20. data/lib/boxcars/conversation_prompt.rb +3 -3
  21. data/lib/boxcars/engine/anthropic.rb +121 -23
  22. data/lib/boxcars/engine/cerebras.rb +2 -2
  23. data/lib/boxcars/engine/cohere.rb +135 -9
  24. data/lib/boxcars/engine/gemini_ai.rb +151 -76
  25. data/lib/boxcars/engine/google.rb +2 -2
  26. data/lib/boxcars/engine/gpt4all_eng.rb +92 -34
  27. data/lib/boxcars/engine/groq.rb +124 -73
  28. data/lib/boxcars/engine/intelligence_base.rb +52 -17
  29. data/lib/boxcars/engine/ollama.rb +127 -47
  30. data/lib/boxcars/engine/openai.rb +186 -103
  31. data/lib/boxcars/engine/perplexityai.rb +116 -136
  32. data/lib/boxcars/engine/together.rb +2 -2
  33. data/lib/boxcars/engine/unified_observability.rb +430 -0
  34. data/lib/boxcars/engine.rb +4 -3
  35. data/lib/boxcars/engines.rb +74 -0
  36. data/lib/boxcars/observability.rb +44 -0
  37. data/lib/boxcars/observability_backend.rb +17 -0
  38. data/lib/boxcars/observability_backends/multi_backend.rb +42 -0
  39. data/lib/boxcars/observability_backends/posthog_backend.rb +89 -0
  40. data/lib/boxcars/observation.rb +8 -8
  41. data/lib/boxcars/prompt.rb +16 -4
  42. data/lib/boxcars/result.rb +7 -12
  43. data/lib/boxcars/ruby_repl.rb +1 -1
  44. data/lib/boxcars/train/train_action.rb +1 -1
  45. data/lib/boxcars/train/xml_train.rb +3 -3
  46. data/lib/boxcars/train/xml_zero_shot.rb +1 -1
  47. data/lib/boxcars/train/zero_shot.rb +3 -3
  48. data/lib/boxcars/train.rb +1 -1
  49. data/lib/boxcars/vector_search.rb +5 -5
  50. data/lib/boxcars/vector_store/pgvector/build_from_array.rb +116 -88
  51. data/lib/boxcars/vector_store/pgvector/build_from_files.rb +106 -80
  52. data/lib/boxcars/vector_store/pgvector/save_to_database.rb +148 -122
  53. data/lib/boxcars/vector_store/pgvector/search.rb +157 -131
  54. data/lib/boxcars/vector_store.rb +4 -4
  55. data/lib/boxcars/version.rb +1 -1
  56. data/lib/boxcars.rb +31 -20
  57. metadata +11 -21
@@ -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
@@ -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: :google, 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: :google, description:, name:, prompts:, batch_size:, **)
26
26
  end
27
27
 
28
28
  def default_model_params
@@ -1,56 +1,114 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'gpt4all'
4
- # Boxcars is a framework for running a series of tools to get an answer to a question.
4
+ require 'json' # For pretty_generate
5
+
5
6
  module Boxcars
6
7
  # A engine that uses local GPT4All API.
7
8
  class Gpt4allEng < Engine
8
- attr_reader :prompts, :model_kwargs, :batch_size
9
+ include UnifiedObservability
10
+ attr_reader :prompts, :model_kwargs, :batch_size, :gpt4all_params # Added gpt4all_params
9
11
 
10
- # the default name of the engine
11
12
  DEFAULT_NAME = "Gpt4all engine"
12
- # the default description of the engine
13
13
  DEFAULT_DESCRIPTION = "useful for when you need to use local AI to answer questions. " \
14
14
  "You should ask targeted questions"
15
+ # GPT4All doesn't have typical API params like temperature or model selection via params in the same way.
16
+ # Model is usually pre-loaded. We can add a placeholder for model_name if needed for tracking.
17
+ DEFAULT_PARAMS = {
18
+ model_name: "gpt4all-j-v1.3-groovy" # Example, actual model depends on local setup
19
+ }.freeze
15
20
 
16
- # A engine is a container for a single tool to run.
17
- # @param name [String] The name of the engine. Defaults to "OpenAI engine".
18
- # @param description [String] A description of the engine. Defaults to:
19
- # useful for when you need to use AI to answer questions. You should ask targeted questions".
20
- # @param prompts [Array<String>] The prompts to use when asking the engine. Defaults to [].
21
- # @param batch_size [Integer] The number of prompts to send to the engine at once. Defaults to 2.
22
- def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 2, **_kwargs)
21
+ def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 2, **kwargs)
22
+ @gpt4all_params = DEFAULT_PARAMS.merge(kwargs) # Store merged params
23
23
  @prompts = prompts
24
- @batch_size = batch_size
25
- super(description: description, name: name)
24
+ @batch_size = batch_size # Retain if used by other methods
25
+ super(description:, name:)
26
26
  end
27
27
 
28
- # Get an answer from the engine.
29
- # @param prompt [String] The prompt to use when asking the engine.
30
- # @param openai_access_token [String] The access token to use when asking the engine.
31
- # Defaults to Boxcars.configuration.openai_access_token.
32
- # @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
33
- def client(prompt:, inputs: {}, **_kwargs)
34
- gpt4all = Gpt4all::ConversationalAI.new
35
- gpt4all.prepare_resources(force_download: false)
36
- gpt4all.start_bot
37
- input_text = prompt.as_prompt(inputs: inputs)[:prompt]
38
- Boxcars.debug("Prompt after formatting:\n#{input_text}", :cyan) if Boxcars.configuration.log_prompts
39
- gpt4all.prompt(input_text)
40
- rescue StandardError => e
41
- Boxcars.error(["Error from gpt4all engine: #{e}", e.backtrace[-5..-1]].flatten.join("\n "))
42
- ensure
43
- gpt4all.stop_bot
28
+ def client(prompt:, inputs: {}, **kwargs)
29
+ start_time = Time.now
30
+ response_data = { response_obj: nil, parsed_json: nil, success: false, error: nil, status_code: nil }
31
+ # current_params are the effective parameters for this call, including defaults and overrides
32
+ current_params = @gpt4all_params.merge(kwargs)
33
+ # api_request_params for GPT4All is just the input text.
34
+ api_request_params = nil
35
+ current_prompt_object = prompt.is_a?(Array) ? prompt.first : prompt
36
+ gpt4all_instance = nil # To ensure it's in scope for ensure block
37
+
38
+ begin
39
+ gpt4all_instance = Gpt4all::ConversationalAI.new
40
+ # prepare_resources might download models, could take time.
41
+ # Consider if this setup should be outside the timed/tracked client call for long-running setup.
42
+ # For now, including it as it's part of the interaction.
43
+ gpt4all_instance.prepare_resources(force_download: false)
44
+ gpt4all_instance.start_bot
45
+
46
+ # GPT4All gem's prompt method takes a string.
47
+ prompt_text_for_api = current_prompt_object.as_prompt(inputs:)
48
+ prompt_text_for_api = prompt_text_for_api[:prompt] if prompt_text_for_api.is_a?(Hash) && prompt_text_for_api.key?(:prompt)
49
+ api_request_params = { prompt: prompt_text_for_api } # Store what's sent
50
+
51
+ Boxcars.debug("Prompt after formatting:\n#{prompt_text_for_api}", :cyan) if Boxcars.configuration.log_prompts
52
+
53
+ raw_response_text = gpt4all_instance.prompt(prompt_text_for_api) # Actual call
54
+
55
+ # GPT4All gem returns a string directly, or raises error.
56
+ response_data[:response_obj] = raw_response_text # Store the raw string
57
+ response_data[:parsed_json] = { "text" => raw_response_text } # Create a simple hash for consistency
58
+ response_data[:success] = true
59
+ response_data[:status_code] = 200 # Inferred for local success
60
+ rescue StandardError => e
61
+ response_data[:error] = e
62
+ response_data[:success] = false
63
+ # No HTTP status code for local errors typically, unless the gem provides one.
64
+ ensure
65
+ gpt4all_instance&.stop_bot # Ensure bot is stopped even if errors occur
66
+
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(:prompt) # The text prompt
72
+ }
73
+
74
+ track_ai_generation(
75
+ duration_ms:,
76
+ current_params:,
77
+ request_context:,
78
+ response_data:,
79
+ provider: :gpt4all
80
+ )
81
+ end
82
+
83
+ _gpt4all_handle_call_outcome(response_data:)
44
84
  end
45
85
 
46
- # get an answer from the engine for a question.
47
- # @param question [String] The question to ask the engine.
48
- # @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
49
- def run(question, **kwargs)
86
+ def run(question, **)
50
87
  prompt = Prompt.new(template: question)
51
- answer = client(prompt: prompt, **kwargs)
88
+ answer = client(prompt:, inputs: {}, **)
52
89
  Boxcars.debug("Answer: #{answer}", :cyan)
53
90
  answer
54
91
  end
92
+
93
+ # Added for consistency
94
+ def default_params
95
+ @gpt4all_params
96
+ end
97
+
98
+ private
99
+
100
+ def _gpt4all_handle_call_outcome(response_data:)
101
+ if response_data[:error]
102
+ # The original code had a specific error logging format.
103
+ Boxcars.error(["Error from gpt4all engine: #{response_data[:error].message}",
104
+ response_data[:error].backtrace&.first(5)&.join("\n ")].compact.join("\n "), :red)
105
+ raise response_data[:error]
106
+ elsif !response_data[:success]
107
+ # This case might be redundant if gpt4all gem always raises on error
108
+ raise Error, "Unknown error from Gpt4all"
109
+ else
110
+ response_data.dig(:parsed_json, "text") # Extract the text from our structured hash
111
+ end
112
+ end
55
113
  end
56
114
  end