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
@@ -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
@@ -1,119 +1,170 @@
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' # Groq uses the OpenAI gem with a custom URI base
4
+ require 'json'
5
+
4
6
  module Boxcars
5
7
  # A engine that uses Groq's API.
6
8
  class Groq < Engine
7
- attr_reader :prompts, :groq_parmas, :model_kwargs, :batch_size
9
+ include UnifiedObservability
10
+ attr_reader :prompts, :groq_params, :model_kwargs, :batch_size
8
11
 
9
- # The default parameters to use when asking the engine.
10
12
  DEFAULT_PARAMS = {
11
13
  model: "llama3-70b-8192",
12
14
  temperature: 0.1,
13
- max_tokens: 4096
15
+ max_tokens: 4096 # Groq API might have specific limits or naming for this
14
16
  }.freeze
15
-
16
- # the default name of the engine
17
17
  DEFAULT_NAME = "Groq engine"
18
- # the default description of the engine
19
- DEFAULT_DESCRIPTION = "useful for when you need to use AI to answer questions. " \
18
+ DEFAULT_DESCRIPTION = "useful for when you need to use Groq AI to answer questions. " \
20
19
  "You should ask targeted questions"
21
20
 
22
- # A engine is a container for a single tool to run.
23
- # @param name [String] The name of the engine. Defaults to "Groq engine".
24
- # @param description [String] A description of the engine. Defaults to:
25
- # useful for when you need to use AI to answer questions. You should ask targeted questions".
26
- # @param prompts [Array<String>] The prompts to use when asking the engine. Defaults to [].
27
- # @param batch_size [Integer] The number of prompts to send to the engine at once. Defaults to 20.
28
21
  def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **kwargs)
29
- @groq_parmas = DEFAULT_PARAMS.merge(kwargs)
22
+ @groq_params = DEFAULT_PARAMS.merge(kwargs) # Corrected typo here
30
23
  @prompts = prompts
31
24
  @batch_size = batch_size
32
- super(description: description, name: name)
25
+ super(description:, name:)
33
26
  end
34
27
 
35
- # Get the OpenAI API client
36
- # @param groq_api_key [String] The access token to use when asking the engine.
37
- # Defaults to Boxcars.configuration.groq_api_key
38
- # @return [OpenAI::Client] The OpenAI API gem client.
39
- def self.open_ai_client(groq_api_key: nil)
40
- access_token = Boxcars.configuration.groq_api_key(groq_api_key: groq_api_key)
41
- ::OpenAI::Client.new(access_token: access_token, uri_base: "https://api.groq.com/openai")
28
+ # Renamed from open_ai_client to groq_client for clarity
29
+ def self.groq_client(groq_api_key: nil)
30
+ access_token = Boxcars.configuration.groq_api_key(groq_api_key:)
31
+ ::OpenAI::Client.new(access_token:, uri_base: "https://api.groq.com/openai/v1")
32
+ # Adjusted uri_base to include /v1 as is common for OpenAI-compatible APIs
42
33
  end
43
34
 
44
- def conversation_model?(_model)
35
+ # Groq models are typically conversational.
36
+ def conversation_model?(_model_name)
45
37
  true
46
38
  end
47
39
 
48
- # Get an answer from the engine.
49
- # @param prompt [String] The prompt to use when asking the engine.
50
- # @param groq_api_key [String] The access token to use when asking the engine.
51
- # Defaults to Boxcars.configuration.groq_api_key.
52
- # @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
53
40
  def client(prompt:, inputs: {}, groq_api_key: nil, **kwargs)
54
- clnt = Groq.open_ai_client(groq_api_key: groq_api_key)
55
- params = groq_parmas.merge(kwargs)
56
- prompt = prompt.first if prompt.is_a?(Array)
57
- params = prompt.as_messages(inputs).merge(params)
58
- if Boxcars.configuration.log_prompts
59
- Boxcars.debug(params[:messages].last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
41
+ start_time = Time.now
42
+ response_data = { response_obj: nil, parsed_json: nil, success: false, error: nil, status_code: nil }
43
+ current_params = @groq_params.merge(kwargs)
44
+ current_prompt_object = prompt.is_a?(Array) ? prompt.first : prompt
45
+ api_request_params = nil # Initialize
46
+
47
+ begin
48
+ clnt = Groq.groq_client(groq_api_key:)
49
+ api_request_params = _prepare_groq_request_params(current_prompt_object, inputs, current_params)
50
+
51
+ log_messages_debug(api_request_params[:messages]) if Boxcars.configuration.log_prompts && api_request_params[:messages]
52
+
53
+ _execute_and_process_groq_call(clnt, api_request_params, response_data)
54
+ rescue ::OpenAI::Error => e
55
+ _handle_openai_error_for_groq(e, response_data)
56
+ rescue StandardError => e
57
+ _handle_standard_error_for_groq(e, response_data)
58
+ ensure
59
+ duration_ms = ((Time.now - start_time) * 1000).round
60
+ request_context = {
61
+ prompt: current_prompt_object,
62
+ inputs:,
63
+ conversation_for_api: api_request_params&.dig(:messages)
64
+ }
65
+ track_ai_generation(
66
+ duration_ms:,
67
+ current_params:,
68
+ request_context:,
69
+ response_data:,
70
+ provider: :groq
71
+ )
60
72
  end
61
- clnt.chat(parameters: params)
62
- rescue => e
63
- Boxcars.error(e, :red)
64
- raise
73
+
74
+ _groq_handle_call_outcome(response_data:)
65
75
  end
66
76
 
67
- # get an answer from the engine for a question.
68
- # @param question [String] The question to ask the engine.
69
- # @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
70
- def run(question, **kwargs)
77
+ def run(question, **)
71
78
  prompt = Prompt.new(template: question)
72
- response = client(prompt: prompt, **kwargs)
73
- raise Error, "Groq: No response from API" unless response
74
-
75
- check_response(response)
76
- response["choices"].map { |c| c.dig("message", "content") || c["text"] }.join("\n").strip
79
+ answer = client(prompt:, inputs: {}, **)
80
+ Boxcars.debug("Answer: #{answer}", :cyan)
81
+ answer
77
82
  end
78
83
 
79
- # Get the default parameters for the engine.
80
84
  def default_params
81
- groq_parmas
85
+ @groq_params # Use instance variable
86
+ end
87
+
88
+ private
89
+
90
+ # Helper methods for the client method
91
+ def _prepare_groq_request_params(prompt_object, inputs, current_params)
92
+ messages_hash_from_prompt = prompt_object.as_messages(inputs)
93
+ actual_messages_for_api = messages_hash_from_prompt[:messages]
94
+ { messages: actual_messages_for_api }.merge(current_params)
82
95
  end
83
96
 
84
- # make sure we got a valid response
85
- # @param response [Hash] The response to check.
86
- # @param must_haves [Array<String>] The keys that must be in the response. Defaults to %w[choices].
87
- # @raise [KeyError] if there is an issue with the access token.
88
- # @raise [ValueError] if the response is not valid.
97
+ def _execute_and_process_groq_call(clnt, api_request_params, response_data)
98
+ raw_response = clnt.chat(parameters: api_request_params)
99
+ response_data[:response_obj] = raw_response
100
+ response_data[:parsed_json] = raw_response # OpenAI gem returns Hash
101
+
102
+ if raw_response && !raw_response["error"] && raw_response["choices"]
103
+ response_data[:success] = true
104
+ response_data[:status_code] = 200 # Inferred
105
+ else
106
+ response_data[:success] = false
107
+ err_details = raw_response["error"] if raw_response
108
+ msg = if err_details
109
+ (err_details.is_a?(Hash) ? err_details['message'] : err_details).to_s
110
+ else
111
+ "Unknown Groq API Error"
112
+ end
113
+ response_data[:error] ||= StandardError.new(msg) # Use ||= to not overwrite existing exception
114
+ end
115
+ end
116
+
117
+ def _handle_openai_error_for_groq(error, response_data)
118
+ response_data[:error] = error
119
+ response_data[:success] = false
120
+ response_data[:status_code] = error.http_status if error.respond_to?(:http_status)
121
+ end
122
+
123
+ def _handle_standard_error_for_groq(error, response_data)
124
+ response_data[:error] = error
125
+ response_data[:success] = false
126
+ end
127
+
128
+ def log_messages_debug(messages)
129
+ return unless messages.is_a?(Array)
130
+
131
+ Boxcars.debug(messages.last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
132
+ end
133
+
134
+ def _groq_handle_call_outcome(response_data:)
135
+ if response_data[:error]
136
+ Boxcars.error("Groq Error: #{response_data[:error].message} (#{response_data[:error].class.name})", :red)
137
+ raise response_data[:error]
138
+ elsif !response_data[:success]
139
+ err_details = response_data.dig(:response_obj, "error")
140
+ msg = if err_details
141
+ err_details.is_a?(Hash) ? "#{err_details['type']}: #{err_details['message']}" : err_details.to_s
142
+ else
143
+ "Unknown error from Groq API"
144
+ end
145
+ raise Error, msg
146
+ else
147
+ choices = response_data.dig(:parsed_json, "choices")
148
+ raise Error, "Groq: No choices found in response" unless choices.is_a?(Array) && !choices.empty?
149
+
150
+ choices.map { |c| c.dig("message", "content") || c["text"] }.join("\n").strip
151
+ end
152
+ end
153
+
154
+ # Retaining check_response if run method or other parts still use it.
89
155
  def check_response(response, must_haves: %w[choices])
90
156
  if response['error'].is_a?(Hash)
91
157
  code = response.dig('error', 'code')
92
158
  msg = response.dig('error', 'message') || 'unknown error'
93
- raise KeyError, "GROQ_API_TOKEN not valid" if code == 'invalid_api_key'
159
+ # GROQ_API_TOKEN is not standard, usually it's an API key.
160
+ raise KeyError, "Groq API Key not valid or permission issue" if ['invalid_api_key', 'permission_denied'].include?(code)
94
161
 
95
162
  raise ValueError, "Groq error: #{msg}"
96
163
  end
97
164
 
98
165
  must_haves.each do |key|
99
- raise ValueError, "Expecting key #{key} in response" unless response.key?(key)
166
+ raise ValueError, "Expecting key #{key} in response" unless response.key?(key) && !response[key].empty?
100
167
  end
101
168
  end
102
-
103
- # the engine type
104
- def engine_type
105
- "groq"
106
- end
107
-
108
- # Calculate the maximum number of tokens possible to generate for a prompt.
109
- # @param prompt_text [String] The prompt text to use.
110
- # @return [Integer] the number of tokens possible to generate.
111
- def max_tokens_for_prompt(prompt_text)
112
- num_tokens = get_num_tokens(prompt_text)
113
-
114
- # get max context size for model by name
115
- max_size = 8096
116
- max_size - num_tokens
117
- end
118
169
  end
119
170
  end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'intelligence'
4
+ require_relative 'unified_observability'
4
5
 
5
6
  module Boxcars
6
7
  # A Base class for all Intelligence Engines
7
8
  class IntelligenceBase < Engine
9
+ include Boxcars::UnifiedObservability
8
10
  attr_reader :provider, :all_params
9
11
 
10
12
  # The base Intelligence Engine is used by other engines to generate output from prompts
@@ -16,8 +18,9 @@ module Boxcars
16
18
  # @param kwargs [Hash] Additional parameters to pass to the Engine.
17
19
  def initialize(provider:, description:, name:, prompts: [], batch_size: 20, **kwargs)
18
20
  @provider = provider
21
+ # Start with defaults, merge other kwargs, then explicitly set model if provided in initialize
19
22
  @all_params = default_model_params.merge(kwargs)
20
- super(description: description, name: name, prompts: prompts, batch_size: batch_size)
23
+ super(description:, name:, prompts:, batch_size:)
21
24
  end
22
25
 
23
26
  # can be overridden by provider subclass
@@ -30,9 +33,10 @@ module Boxcars
30
33
  end
31
34
 
32
35
  def adapter(params:, api_key:)
33
- Intelligence::Adapter[provider].new(
34
- { key: api_key, chat_options: params }
35
- )
36
+ Intelligence::Adapter.build! @provider do |config|
37
+ config.key api_key
38
+ config.chat_options params
39
+ end
36
40
  end
37
41
 
38
42
  # Process different content types
@@ -59,25 +63,56 @@ module Boxcars
59
63
  # Get an answer from the engine
60
64
  def client(prompt:, inputs: {}, api_key: nil, **kwargs)
61
65
  params = all_params.merge(kwargs)
62
- api_key ||= lookup_provider_api_key(params: params)
66
+ api_key ||= lookup_provider_api_key(params:)
63
67
  raise Error, "No API key found for #{provider}" unless api_key
64
68
 
65
- adapter = adapter(api_key: api_key, params: params)
66
- convo = prompt.as_intelligence_conversation(inputs: inputs)
67
- request = Intelligence::ChatRequest.new(adapter: adapter)
68
- response = request.chat(convo)
69
- return JSON.parse(response.body) if response.success?
70
-
71
- raise Error, (response&.reason_phrase || "No response from API #{provider}")
72
- rescue StandardError => e
73
- Boxcars.error("#{provider} Error: #{e.message}", :red)
74
- raise
69
+ adapter = adapter(api_key:, params:)
70
+ convo = prompt.as_intelligence_conversation(inputs:)
71
+ request_context = { prompt: prompt&.as_prompt(inputs:)&.[](:prompt), inputs:, conversation_for_api: convo.to_h }
72
+ request = Intelligence::ChatRequest.new(adapter:)
73
+
74
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
75
+ response_obj = nil
76
+
77
+ begin
78
+ response_obj = request.chat(convo)
79
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000
80
+
81
+ if response_obj.success?
82
+ success = true
83
+ parsed_json_response = JSON.parse(response_obj.body)
84
+ response_data = { success:, parsed_json: parsed_json_response, response_obj:,
85
+ status_code: response_obj.status }
86
+ track_ai_generation(duration_ms:, current_params: params, request_context:, response_data:, provider:)
87
+ parsed_json_response
88
+ else
89
+ success = false
90
+ error_message = response_obj&.reason_phrase || "No response from API #{provider}"
91
+ response_data = { success:, error: StandardError.new(error_message), response_obj:,
92
+ status_code: response_obj.status }
93
+ track_ai_generation(duration_ms:, current_params: params, request_context:, response_data:, provider:)
94
+ raise Error, error_message
95
+ end
96
+ rescue Error => e
97
+ # Re-raise Error exceptions (like the one above) without additional tracking
98
+ # since they were already tracked in the else branch
99
+ Boxcars.error("#{provider} Error: #{e.message}", :red)
100
+ raise
101
+ rescue StandardError => e
102
+ duration_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000
103
+ success = false
104
+ error_obj = e
105
+ response_data = { success:, error: error_obj, response_obj:, status_code: response_obj&.status }
106
+ track_ai_generation(duration_ms:, current_params: params, request_context:, response_data:, provider:)
107
+ Boxcars.error("#{provider} Error: #{e.message}", :red)
108
+ raise
109
+ end
75
110
  end
76
111
 
77
112
  # Run the engine with a question
78
- def run(question, **kwargs)
113
+ def run(question, **)
79
114
  prompt = Prompt.new(template: question)
80
- response = client(prompt: prompt, **kwargs)
115
+ response = client(prompt:, **)
81
116
  extract_answer(response)
82
117
  end
83
118