boxcars 0.7.7 → 0.8.1

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 +17 -0
  5. data/Gemfile +3 -13
  6. data/Gemfile.lock +30 -25
  7. data/POSTHOG_TEST_README.md +118 -0
  8. data/README.md +305 -0
  9. data/boxcars.gemspec +2 -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 +115 -88
  52. data/lib/boxcars/vector_store/pgvector/build_from_files.rb +105 -80
  53. data/lib/boxcars/vector_store/pgvector/save_to_database.rb +147 -122
  54. data/lib/boxcars/vector_store/pgvector/search.rb +156 -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 +25 -21
@@ -1,161 +1,244 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'openai'
4
- # Boxcars is a framework for running a series of tools to get an answer to a question.
4
+ require 'json'
5
+ require 'securerandom'
6
+
5
7
  module Boxcars
6
8
  # A engine that uses OpenAI's API.
7
9
  class Openai < Engine
10
+ include UnifiedObservability
8
11
  attr_reader :prompts, :open_ai_params, :model_kwargs, :batch_size
9
12
 
10
- # The default parameters to use when asking the engine.
11
13
  DEFAULT_PARAMS = {
12
14
  model: "gpt-4o-mini",
13
15
  temperature: 0.1,
14
16
  max_tokens: 4096
15
17
  }.freeze
16
-
17
- # the default name of the engine
18
18
  DEFAULT_NAME = "OpenAI engine"
19
- # the default description of the engine
20
19
  DEFAULT_DESCRIPTION = "useful for when you need to use AI to answer questions. " \
21
20
  "You should ask targeted questions"
22
21
 
23
- # A engine is a container for a single tool to run.
24
- # @param name [String] The name of the engine. Defaults to "OpenAI engine".
25
- # @param description [String] A description of the engine. Defaults to:
26
- # useful for when you need to use AI to answer questions. You should ask targeted questions".
27
- # @param prompts [Array<String>] The prompts to use when asking the engine. Defaults to [].
28
- # @param batch_size [Integer] The number of prompts to send to the engine at once. Defaults to 20.
29
22
  def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **kwargs)
30
23
  @open_ai_params = DEFAULT_PARAMS.merge(kwargs)
31
- if @open_ai_params[:model] =~ /^o/ && @open_ai_params[:max_tokens].present?
24
+ # Special handling for o1-mini model (deprecated?)
25
+ if @open_ai_params[:model] =~ /^o/ && @open_ai_params[:max_tokens]
32
26
  @open_ai_params[:max_completion_tokens] = @open_ai_params.delete(:max_tokens)
33
- @open_ai_params.delete(:temperature)
27
+ @open_ai_params.delete(:temperature) # o1-mini might not support temperature
34
28
  end
35
29
 
36
30
  @prompts = prompts
37
31
  @batch_size = batch_size
38
- super(description: description, name: name)
32
+ super(description:, name:)
39
33
  end
40
34
 
41
- # Get the OpenAI API client
42
- # @param openai_access_token [String] The access token to use when asking the engine.
43
- # Defaults to Boxcars.configuration.openai_access_token.
44
- # @return [OpenAI::Client] The OpenAI API client.
45
35
  def self.open_ai_client(openai_access_token: nil)
46
- access_token = Boxcars.configuration.openai_access_token(openai_access_token: openai_access_token)
36
+ access_token = Boxcars.configuration.openai_access_token(openai_access_token:)
47
37
  organization_id = Boxcars.configuration.organization_id
48
- ::OpenAI::Client.new(access_token: access_token, organization_id: organization_id, log_errors: true)
38
+ # log_errors is good for the gem's own logging
39
+ ::OpenAI::Client.new(access_token:, organization_id:, log_errors: true)
49
40
  end
50
41
 
51
- def conversation_model?(model)
52
- !!(model =~ /(^gpt-4)|(-turbo\b)|(^o\d)/)
42
+ def conversation_model?(model_name)
43
+ !!(model_name =~ /(^gpt-4)|(-turbo\b)|(^o\d)|(gpt-3\.5-turbo)/) # Added gpt-3.5-turbo
53
44
  end
54
45
 
55
- # Get an answer from the engine.
56
- # @param prompt [String] The prompt to use when asking the engine.
57
- # @param openai_access_token [String] The access token to use when asking the engine.
58
- # Defaults to Boxcars.configuration.openai_access_token.
59
- # @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
60
- def client(prompt:, inputs: {}, openai_access_token: nil, **kwargs)
61
- clnt = Openai.open_ai_client(openai_access_token: openai_access_token)
62
- params = open_ai_params.merge(kwargs)
63
- if conversation_model?(params[:model])
64
- prompt = prompt.first if prompt.is_a?(Array)
65
- if params[:model] =~ /^o/
66
- params.delete(:response_format)
67
- params.delete(:stop)
68
- end
69
- params = get_params(prompt, inputs, params)
70
- clnt.chat(parameters: params)
46
+ def _prepare_openai_chat_request(prompt_object, inputs, current_params)
47
+ get_params(prompt_object, inputs, current_params.dup)
48
+ end
49
+
50
+ def _prepare_openai_completion_request(prompt_object, inputs, current_params)
51
+ prompt_text_for_api = prompt_object.as_prompt(inputs:)
52
+ prompt_text_for_api = prompt_text_for_api[:prompt] if prompt_text_for_api.is_a?(Hash) && prompt_text_for_api.key?(:prompt)
53
+ { prompt: prompt_text_for_api }.merge(current_params).tap { |p| p.delete(:messages) }
54
+ end
55
+
56
+ def _execute_openai_api_call(client, is_chat_model, api_request_params)
57
+ if is_chat_model
58
+ log_messages_debug(api_request_params[:messages]) if Boxcars.configuration.log_prompts && api_request_params[:messages]
59
+ client.chat(parameters: api_request_params)
71
60
  else
72
- params = prompt.as_prompt(inputs: inputs).merge(params)
73
- Boxcars.debug("Prompt after formatting:\n#{params[:prompt]}", :cyan) if Boxcars.configuration.log_prompts
74
- clnt.completions(parameters: params)
61
+ Boxcars.debug("Prompt after formatting:\n#{api_request_params[:prompt]}", :cyan) if Boxcars.configuration.log_prompts
62
+ client.completions(parameters: api_request_params)
75
63
  end
76
- rescue StandardError => e
77
- err = e.respond_to?(:response) ? e.response[:body] : e
78
- Boxcars.warn("OpenAI Error #{e.class.name}: #{err}", :red)
79
- raise
80
64
  end
81
65
 
82
- # get an answer from the engine for a question.
83
- # @param question [String] The question to ask the engine.
84
- # @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
85
- def run(question, **kwargs)
86
- prompt = Prompt.new(template: question)
87
- response = client(prompt: prompt, **kwargs)
88
- raise Error, "OpenAI: No response from API" unless response
89
- raise Error, "OpenAI: #{response['error']}" if response["error"]
66
+ def _process_openai_response(raw_response, response_data)
67
+ response_data[:response_obj] = raw_response
68
+ response_data[:parsed_json] = raw_response # Already parsed by OpenAI gem
69
+
70
+ if raw_response && !raw_response["error"]
71
+ response_data[:success] = true
72
+ response_data[:status_code] = 200 # Inferred
73
+ else
74
+ response_data[:success] = false
75
+ err_details = raw_response["error"] if raw_response
76
+ msg = err_details ? "#{err_details['type']}: #{err_details['message']}" : "Unknown OpenAI API Error"
77
+ response_data[:error] ||= StandardError.new(msg) # Use ||= to not overwrite existing exception
78
+ end
79
+ end
80
+
81
+ def _handle_openai_api_error(error, response_data)
82
+ response_data[:error] = error
83
+ response_data[:success] = false
84
+ response_data[:status_code] = error.http_status if error.respond_to?(:http_status)
85
+ end
86
+
87
+ def _handle_openai_standard_error(error, response_data)
88
+ response_data[:error] = error
89
+ response_data[:success] = false
90
+ end
91
+
92
+ def client(prompt:, inputs: {}, openai_access_token: nil, **kwargs)
93
+ start_time = Time.now
94
+ response_data = { response_obj: nil, parsed_json: nil, success: false, error: nil, status_code: nil }
95
+ current_params = open_ai_params.merge(kwargs)
96
+ current_prompt_object = prompt.is_a?(Array) ? prompt.first : prompt
97
+ api_request_params = nil
98
+ is_chat_model = conversation_model?(current_params[:model])
99
+
100
+ begin
101
+ clnt = Openai.open_ai_client(openai_access_token:)
102
+ api_request_params = if is_chat_model
103
+ _prepare_openai_chat_request(current_prompt_object, inputs, current_params)
104
+ else
105
+ _prepare_openai_completion_request(current_prompt_object, inputs, current_params)
106
+ end
107
+ raw_response = _execute_openai_api_call(clnt, is_chat_model, api_request_params)
108
+ _process_openai_response(raw_response, response_data)
109
+ rescue ::OpenAI::Error => e
110
+ _handle_openai_api_error(e, response_data)
111
+ rescue StandardError => e
112
+ _handle_openai_standard_error(e, response_data)
113
+ ensure
114
+ call_context = {
115
+ start_time:,
116
+ prompt_object: current_prompt_object,
117
+ inputs:,
118
+ api_request_params:,
119
+ current_params:,
120
+ is_chat_model:
121
+ }
122
+ _track_openai_observability(call_context, response_data)
123
+ end
124
+
125
+ _openai_handle_call_outcome(response_data:)
126
+ end
127
+
128
+ # Called by Engine#generate to check the response from the client.
129
+ # @param response [Hash] The parsed JSON response from the OpenAI API.
130
+ # @raise [Boxcars::Error] if the response contains an error.
131
+ def check_response(response)
132
+ if response.is_a?(Hash) && response["error"]
133
+ err_details = response["error"]
134
+ msg = err_details ? "#{err_details['type']}: #{err_details['message']}" : "Unknown OpenAI API Error in check_response"
135
+ raise Boxcars::Error, msg
136
+ end
137
+ true
138
+ end
90
139
 
91
- answer = response["choices"].map { |c| c.dig("message", "content") || c["text"] }.join("\n").strip
140
+ def run(question, **)
141
+ prompt = Prompt.new(template: question)
142
+ # client now returns the raw JSON response. We need to extract the answer.
143
+ raw_response = client(prompt:, inputs: {}, **)
144
+ answer = _extract_openai_answer_from_choices(raw_response["choices"])
92
145
  Boxcars.debug("Answer: #{answer}", :cyan)
93
146
  answer
94
147
  end
95
148
 
96
- # Get the default parameters for the engine.
97
149
  def default_params
98
150
  open_ai_params
99
151
  end
100
152
 
101
- # make sure we got a valid response
102
- # @param response [Hash] The response to check.
103
- # @param must_haves [Array<String>] The keys that must be in the response. Defaults to %w[choices].
104
- # @raise [KeyError] if there is an issue with the access token.
105
- # @raise [ValueError] if the response is not valid.
106
- def check_response(response, must_haves: %w[choices])
107
- if response['error']
108
- code = response.dig('error', 'code')
109
- msg = response.dig('error', 'message') || 'unknown error'
110
- raise KeyError, "OPENAI_ACCESS_TOKEN not valid" if code == 'invalid_api_key'
153
+ private
111
154
 
112
- raise ValueError, "OpenAI error: #{msg}"
113
- end
155
+ def log_messages_debug(messages)
156
+ return unless messages.is_a?(Array)
114
157
 
115
- must_haves.each do |key|
116
- raise ValueError, "Expecting key #{key} in response" unless response.key?(key)
117
- end
158
+ Boxcars.debug(messages.last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
118
159
  end
119
160
 
120
- def get_params(prompt, inputs, params)
121
- params = prompt.as_messages(inputs).merge(params)
161
+ def get_params(prompt_object, inputs, params)
162
+ # Ensure prompt_object is a Boxcars::Prompt
163
+ current_prompt_object = if prompt_object.is_a?(Boxcars::Prompt)
164
+ prompt_object
165
+ else
166
+ Boxcars::Prompt.new(template: prompt_object.to_s)
167
+ end
168
+
169
+ # Use as_messages for chat models
170
+ formatted_params = current_prompt_object.as_messages(inputs).merge(params)
171
+
122
172
  # Handle models like o1-mini that don't support the system role
123
- params[:messages].first[:role] = :user if params[:model] =~ /^o/ && params[:messages].first&.fetch(:role) == :system
124
- if Boxcars.configuration.log_prompts
125
- Boxcars.debug(params[:messages].last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
173
+ if formatted_params[:model] =~ /^o/ && formatted_params[:messages].first&.fetch(:role)&.to_s == 'system'
174
+ formatted_params[:messages].first[:role] = :user
126
175
  end
127
- params
176
+ # o1-mini specific param adjustments (already in initialize, but good to ensure here if params are rebuilt)
177
+ if formatted_params[:model] =~ /^o/
178
+ formatted_params.delete(:response_format)
179
+ formatted_params.delete(:stop)
180
+ if formatted_params.key?(:max_tokens) && !formatted_params.key?(:max_completion_tokens)
181
+ formatted_params[:max_completion_tokens] = formatted_params.delete(:max_tokens)
182
+ end
183
+ formatted_params.delete(:temperature)
184
+ end
185
+ formatted_params
128
186
  end
129
- end
130
187
 
131
- # the engine type
132
- def engine_type
133
- "openai"
134
- end
188
+ def _handle_openai_error_outcome(error_data)
189
+ detailed_error_message = error_data.message
190
+ if error_data.respond_to?(:json_body) && error_data.json_body
191
+ detailed_error_message += " - Details: #{error_data.json_body}"
192
+ end
193
+ Boxcars.error("OpenAI Error: #{detailed_error_message} (#{error_data.class.name})", :red)
194
+ raise error_data
195
+ end
135
196
 
136
- # lookup the context size for a model by name
137
- # @param modelname [String] The name of the model to lookup.
138
- def modelname_to_contextsize(modelname)
139
- model_lookup = {
140
- 'text-davinci-003': 4097,
141
- 'text-curie-001': 2048,
142
- 'text-babbage-001': 2048,
143
- 'text-ada-001': 2048,
144
- 'code-davinci-002': 8000,
145
- 'code-cushman-001': 2048,
146
- 'gpt-3.5-turbo-1': 4096
147
- }.freeze
148
- model_lookup[modelname] || 4097
149
- end
197
+ def _handle_openai_response_body_error(response_obj)
198
+ err_details = response_obj&.dig("error")
199
+ msg = err_details ? "#{err_details['type']}: #{err_details['message']}" : "Unknown error from OpenAI API"
200
+ raise Error, msg
201
+ end
202
+
203
+ def _extract_openai_answer_from_choices(choices)
204
+ raise Error, "OpenAI: No choices found in response" unless choices.is_a?(Array) && !choices.empty?
205
+
206
+ if choices.first&.dig("message", "content")
207
+ choices.map { |c| c.dig("message", "content") }.join("\n").strip
208
+ elsif choices.first&.dig("text")
209
+ choices.map { |c| c["text"] }.join("\n").strip
210
+ else
211
+ raise Error, "OpenAI: Could not extract answer from choices"
212
+ end
213
+ end
150
214
 
151
- # Calculate the maximum number of tokens possible to generate for a prompt.
152
- # @param prompt_text [String] The prompt text to use.
153
- # @return [Integer] the number of tokens possible to generate.
154
- def max_tokens_for_prompt(prompt_text)
155
- num_tokens = get_num_tokens(prompt_text)
215
+ def _openai_handle_call_outcome(response_data:)
216
+ if response_data[:error]
217
+ _handle_openai_error_outcome(response_data[:error])
218
+ elsif !response_data[:success] # e.g. raw_response["error"] was present
219
+ _handle_openai_response_body_error(response_data[:response_obj]) # Raises an error
220
+ else
221
+ response_data[:parsed_json] # Return the raw parsed JSON for Engine#generate
222
+ end
223
+ end
156
224
 
157
- # get max context size for model by name
158
- max_size = modelname_to_contextsize(model_name)
159
- max_size - num_tokens
225
+ def _track_openai_observability(call_context, response_data)
226
+ duration_ms = ((Time.now - call_context[:start_time]) * 1000).round
227
+ is_chat_model = call_context[:is_chat_model]
228
+ api_request_params = call_context[:api_request_params] || {}
229
+ request_context = {
230
+ prompt: call_context[:prompt_object],
231
+ inputs: call_context[:inputs],
232
+ conversation_for_api: is_chat_model ? api_request_params[:messages] : api_request_params[:prompt]
233
+ }
234
+
235
+ track_ai_generation(
236
+ duration_ms:,
237
+ current_params: call_context[:current_params],
238
+ request_context:,
239
+ response_data:,
240
+ provider: :openai
241
+ )
242
+ end
160
243
  end
161
244
  end
@@ -1,173 +1,153 @@
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 'faraday'
4
+ require 'faraday/retry'
5
+ require 'json'
6
+
4
7
  module Boxcars
5
- # A engine that uses OpenAI's API.
8
+ # A engine that uses PerplexityAI's API.
6
9
  class Perplexityai < Engine
10
+ include UnifiedObservability
7
11
  attr_reader :prompts, :perplexity_params, :model_kwargs, :batch_size
8
12
 
9
- # The default parameters to use when asking the engine.
10
- DEFAULT_PER_PARAMS = {
11
- model: "'llama-3-sonar-large-32k-online'",
13
+ DEFAULT_PARAMS = { # Renamed from DEFAULT_PER_PARAMS for consistency
14
+ model: "llama-3-sonar-large-32k-online", # Removed extra quotes
12
15
  temperature: 0.1
16
+ # max_tokens can be part of kwargs if needed
13
17
  }.freeze
18
+ DEFAULT_NAME = "PerplexityAI engine" # Renamed from DEFAULT_PER_NAME
19
+ DEFAULT_DESCRIPTION = "useful for when you need to use Perplexity AI to answer questions. " \
20
+ "You should ask targeted questions"
14
21
 
15
- # the default name of the engine
16
- DEFAULT_PER_NAME = "PerplexityAI engine"
17
- # the default description of the engine
18
- DEFAULT_PER_DESCRIPTION = "useful for when you need to use AI to answer questions. " \
19
- "You should ask targeted questions"
20
-
21
- # A engine is a container for a single tool to run.
22
- # @param name [String] The name of the engine. Defaults to "PerplexityAI 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
- def initialize(name: DEFAULT_PER_NAME, description: DEFAULT_PER_DESCRIPTION, prompts: [], batch_size: 20, **kwargs)
28
- @perplexity_params = DEFAULT_PER_PARAMS.merge(kwargs)
22
+ def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 20, **kwargs)
23
+ @perplexity_params = DEFAULT_PARAMS.merge(kwargs)
29
24
  @prompts = prompts
30
- @batch_size = batch_size
31
- super(description: description, name: name)
25
+ @batch_size = batch_size # Retain if used by generate
26
+ super(description:, name:)
32
27
  end
33
28
 
34
- def conversation_model?(_model)
29
+ # Perplexity models are conversational.
30
+ def conversation_model?(_model_name)
35
31
  true
36
32
  end
37
33
 
38
- def chat(parameters:)
39
- conn = Faraday.new(url: "https://api.perplexity.ai/chat/completions") do |faraday|
40
- faraday.request :json
41
- faraday.response :json
42
- faraday.response :raise_error
43
- # faraday.options.timeout = 180 # 3 minutes
44
- end
45
-
46
- response = conn.post do |req|
47
- req.headers['Authorization'] = "Bearer #{ENV.fetch('PERPLEXITY_API_KEY')}"
48
- req.body = {
49
- model: parameters[:model],
50
- messages: parameters[:messages]
34
+ # Main client method for interacting with the Perplexity API
35
+ # rubocop:disable Metrics/MethodLength
36
+ def client(prompt:, inputs: {}, perplexity_api_key: nil, **kwargs)
37
+ start_time = Time.now
38
+ response_data = { response_obj: nil, parsed_json: nil, success: false, error: nil, status_code: nil }
39
+ current_params = @perplexity_params.merge(kwargs)
40
+ api_request_params = nil # Parameters actually sent to Perplexity API
41
+ current_prompt_object = prompt.is_a?(Array) ? prompt.first : prompt
42
+
43
+ begin
44
+ api_key = perplexity_api_key || Boxcars.configuration.perplexity_api_key(**current_params.slice(:perplexity_api_key))
45
+ raise Boxcars::ConfigurationError, "Perplexity API key not set" if api_key.nil? || api_key.strip.empty?
46
+
47
+ conn = Faraday.new(url: "https://api.perplexity.ai") do |faraday|
48
+ faraday.request :json
49
+ faraday.response :json # Parse JSON response
50
+ faraday.response :raise_error # Raise exceptions on 4xx/5xx
51
+ faraday.adapter Faraday.default_adapter
52
+ end
53
+
54
+ messages_for_api = current_prompt_object.as_messages(inputs)[:messages]
55
+ # Perplexity expects a 'model' and 'messages' structure.
56
+ # Other params like temperature, max_tokens are top-level.
57
+ api_request_params = {
58
+ model: current_params[:model],
59
+ messages: messages_for_api
60
+ }.merge(current_params.except(:model, :messages, :perplexity_api_key)) # Add other relevant params
61
+
62
+ log_messages_debug(api_request_params[:messages]) if Boxcars.configuration.log_prompts && api_request_params[:messages]
63
+
64
+ response = conn.post('/chat/completions') do |req|
65
+ req.headers['Authorization'] = "Bearer #{api_key}"
66
+ req.body = api_request_params
67
+ end
68
+
69
+ response_data[:response_obj] = response # Faraday response object
70
+ response_data[:parsed_json] = response.body # Faraday with :json middleware parses body
71
+ response_data[:status_code] = response.status
72
+
73
+ if response.success? && response.body && response.body["choices"]
74
+ response_data[:success] = true
75
+ else
76
+ response_data[:success] = false
77
+ err_details = response.body["error"] if response.body.is_a?(Hash)
78
+ msg = if err_details
79
+ "#{err_details['type']}: #{err_details['message']}"
80
+ else
81
+ "Unknown Perplexity API Error (status: #{response.status})"
82
+ end
83
+ response_data[:error] = StandardError.new(msg)
84
+ end
85
+ rescue Faraday::Error => e # Catch Faraday specific errors (includes connection, timeout, 4xx/5xx)
86
+ response_data[:error] = e
87
+ response_data[:success] = false
88
+ response_data[:status_code] = e.response_status if e.respond_to?(:response_status)
89
+ response_data[:response_obj] = e.response if e.respond_to?(:response) # Store Faraday response if available
90
+ response_data[:parsed_json] = e.response[:body] if e.respond_to?(:response) && e.response[:body].is_a?(Hash)
91
+ rescue StandardError => e # Catch other unexpected errors
92
+ response_data[:error] = e
93
+ response_data[:success] = false
94
+ ensure
95
+ duration_ms = ((Time.now - start_time) * 1000).round
96
+ request_context = {
97
+ prompt: current_prompt_object,
98
+ inputs:,
99
+ conversation_for_api: api_request_params&.dig(:messages)
51
100
  }
101
+ track_ai_generation(
102
+ duration_ms:,
103
+ current_params:,
104
+ request_context:,
105
+ response_data:,
106
+ provider: :perplexity_ai
107
+ )
52
108
  end
53
109
 
54
- response.body
55
- end
56
-
57
- # Get an answer from the engine.
58
- # @param prompt [String] The prompt to use when asking the engine.
59
- # @param openai_access_token [String] The access token to use when asking the engine.
60
- # Defaults to Boxcars.configuration.openai_access_token.
61
- # @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
62
- def client(prompt:, inputs: {}, **kwargs)
63
- prompt = prompt.first if prompt.is_a?(Array)
64
- params = prompt.as_messages(inputs).merge(default_params).merge(kwargs)
65
- if Boxcars.configuration.log_prompts
66
- Boxcars.debug(params[:messages].last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
67
- end
68
- chat(parameters: params)
110
+ _perplexity_handle_call_outcome(response_data:)
69
111
  end
112
+ # rubocop:enable Metrics/MethodLength
70
113
 
71
- # get an answer from the engine for a question.
72
- # @param question [String] The question to ask the engine.
73
- # @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
74
- def run(question, **kwargs)
114
+ def run(question, **)
75
115
  prompt = Prompt.new(template: question)
76
- response = client(prompt: prompt, **kwargs)
77
- raise Error, "PerplexityAI: No response from API" unless response
78
- raise Error, "PerplexityAI: #{response['error']}" if response["error"]
79
-
80
- answer = response["choices"].map { |c| c.dig("message", "content") || c["text"] }.join("\n").strip
116
+ answer = client(prompt:, inputs: {}, **)
81
117
  Boxcars.debug("Answer: #{answer}", :cyan)
82
118
  answer
83
119
  end
84
120
 
85
- # Get the default parameters for the engine.
86
121
  def default_params
87
- perplexity_params
88
- end
89
-
90
- # make sure we got a valid response
91
- # @param response [Hash] The response to check.
92
- # @param must_haves [Array<String>] The keys that must be in the response. Defaults to %w[choices].
93
- # @raise [KeyError] if there is an issue with the access token.
94
- # @raise [ValueError] if the response is not valid.
95
- def check_response(response, must_haves: %w[choices])
96
- if response['error']
97
- code = response.dig('error', 'code')
98
- msg = response.dig('error', 'message') || 'unknown error'
99
- raise KeyError, "PERPLEXITY_API_KEY not valid" if code == 'invalid_api_key'
100
-
101
- raise ValueError, "PerplexityAI error: #{msg}"
102
- end
103
-
104
- must_haves.each do |key|
105
- raise ValueError, "Expecting key #{key} in response" unless response.key?(key)
106
- end
122
+ @perplexity_params
107
123
  end
108
- end
109
-
110
- # the engine type
111
- def engine_type
112
- "perplexityai"
113
- end
114
124
 
115
- # calculate the number of tokens used
116
- def get_num_tokens(text:)
117
- text.split.length # TODO: hook up to token counting gem
118
- end
125
+ private
119
126
 
120
- # Calculate the maximum number of tokens possible to generate for a prompt.
121
- # @param prompt_text [String] The prompt text to use.
122
- # @return [Integer] the number of tokens possible to generate.
123
- def max_tokens_for_prompt(_prompt_text)
124
- 8096
125
- end
127
+ def log_messages_debug(messages)
128
+ return unless messages.is_a?(Array)
126
129
 
127
- # Get generation informaton
128
- # @param sub_choices [Array<Hash>] The choices to get generation info for.
129
- # @return [Array<Generation>] The generation information.
130
- def generation_info(sub_choices)
131
- sub_choices.map do |choice|
132
- Generation.new(
133
- text: choice.dig("message", "content") || choice["text"],
134
- generation_info: {
135
- finish_reason: choice.fetch("finish_reason", nil),
136
- logprobs: choice.fetch("logprobs", nil)
137
- }
138
- )
130
+ Boxcars.debug(messages.last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
139
131
  end
140
- end
141
132
 
142
- # Call out to endpoint with k unique prompts.
143
- # @param prompts [Array<String>] The prompts to pass into the model.
144
- # @param inputs [Array<String>] The inputs to subsitite into the prompt.
145
- # @param stop [Array<String>] Optional list of stop words to use when generating.
146
- # @return [EngineResult] The full engine output.
147
- def generate(prompts:, stop: nil)
148
- params = {}
149
- params[:stop] = stop if stop
150
- choices = []
151
- token_usage = {}
152
- # Get the token usage from the response.
153
- # Includes prompt, completion, and total tokens used.
154
- inkeys = %w[completion_tokens prompt_tokens total_tokens].freeze
155
- prompts.each_slice(batch_size) do |sub_prompts|
156
- sub_prompts.each do |sprompts, inputs|
157
- response = client(prompt: sprompts, inputs: inputs, **params)
158
- check_response(response)
159
- choices.concat(response["choices"])
160
- usage_keys = inkeys & response["usage"].keys
161
- usage_keys.each { |key| token_usage[key] = token_usage[key].to_i + response["usage"][key] }
133
+ def _perplexity_handle_call_outcome(response_data:)
134
+ if response_data[:error]
135
+ Boxcars.error("PerplexityAI Error: #{response_data[:error].message} (#{response_data[:error].class.name})", :red)
136
+ raise response_data[:error]
137
+ elsif !response_data[:success]
138
+ err_details = response_data.dig(:parsed_json, "error")
139
+ msg = err_details ? "#{err_details['type']}: #{err_details['message']}" : "Unknown error from PerplexityAI API"
140
+ raise Error, msg
141
+ else
142
+ choices = response_data.dig(:parsed_json, "choices")
143
+ raise Error, "PerplexityAI: No choices found in response" unless choices.is_a?(Array) && !choices.empty?
144
+
145
+ choices.map { |c| c.dig("message", "content") }.join("\n").strip
162
146
  end
163
147
  end
164
148
 
165
- n = params.fetch(:n, 1)
166
- generations = []
167
- prompts.each_with_index do |_prompt, i|
168
- sub_choices = choices[i * n, (i + 1) * n]
169
- generations.push(generation_info(sub_choices))
170
- end
171
- EngineResult.new(generations: generations, engine_output: { token_usage: token_usage })
149
+ # Methods like `check_response`, `generate`, `generation_info` are removed or would need significant rework.
150
+ # `check_response` logic is now part of `_perplexity_handle_call_outcome`.
151
+ # `generate` would need to be re-implemented carefully if batching is desired with direct Faraday.
172
152
  end
173
153
  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: :together_ai, 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: :together_ai, description:, name:, prompts:, batch_size:, **)
26
26
  end
27
27
 
28
28
  def default_model_params