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.
- checksums.yaml +4 -4
- data/.rubocop.yml +6 -3
- data/.ruby-version +1 -1
- data/Gemfile +3 -13
- data/Gemfile.lock +29 -25
- data/POSTHOG_TEST_README.md +118 -0
- data/README.md +305 -0
- data/boxcars.gemspec +1 -2
- data/lib/boxcars/boxcar/active_record.rb +9 -10
- data/lib/boxcars/boxcar/calculator.rb +2 -2
- data/lib/boxcars/boxcar/engine_boxcar.rb +4 -4
- data/lib/boxcars/boxcar/google_search.rb +2 -2
- data/lib/boxcars/boxcar/json_engine_boxcar.rb +1 -1
- data/lib/boxcars/boxcar/ruby_calculator.rb +1 -1
- data/lib/boxcars/boxcar/sql_base.rb +4 -4
- data/lib/boxcars/boxcar/swagger.rb +3 -3
- data/lib/boxcars/boxcar/vector_answer.rb +3 -3
- data/lib/boxcars/boxcar/xml_engine_boxcar.rb +1 -1
- data/lib/boxcars/boxcar.rb +6 -6
- data/lib/boxcars/conversation_prompt.rb +3 -3
- data/lib/boxcars/engine/anthropic.rb +121 -23
- data/lib/boxcars/engine/cerebras.rb +2 -2
- data/lib/boxcars/engine/cohere.rb +135 -9
- data/lib/boxcars/engine/gemini_ai.rb +151 -76
- data/lib/boxcars/engine/google.rb +2 -2
- data/lib/boxcars/engine/gpt4all_eng.rb +92 -34
- data/lib/boxcars/engine/groq.rb +124 -73
- data/lib/boxcars/engine/intelligence_base.rb +52 -17
- data/lib/boxcars/engine/ollama.rb +127 -47
- data/lib/boxcars/engine/openai.rb +186 -103
- data/lib/boxcars/engine/perplexityai.rb +116 -136
- data/lib/boxcars/engine/together.rb +2 -2
- data/lib/boxcars/engine/unified_observability.rb +430 -0
- data/lib/boxcars/engine.rb +4 -3
- data/lib/boxcars/engines.rb +74 -0
- data/lib/boxcars/observability.rb +44 -0
- data/lib/boxcars/observability_backend.rb +17 -0
- data/lib/boxcars/observability_backends/multi_backend.rb +42 -0
- data/lib/boxcars/observability_backends/posthog_backend.rb +89 -0
- data/lib/boxcars/observation.rb +8 -8
- data/lib/boxcars/prompt.rb +16 -4
- data/lib/boxcars/result.rb +7 -12
- data/lib/boxcars/ruby_repl.rb +1 -1
- data/lib/boxcars/train/train_action.rb +1 -1
- data/lib/boxcars/train/xml_train.rb +3 -3
- data/lib/boxcars/train/xml_zero_shot.rb +1 -1
- data/lib/boxcars/train/zero_shot.rb +3 -3
- data/lib/boxcars/train.rb +1 -1
- data/lib/boxcars/vector_search.rb +5 -5
- data/lib/boxcars/vector_store/pgvector/build_from_array.rb +116 -88
- data/lib/boxcars/vector_store/pgvector/build_from_files.rb +106 -80
- data/lib/boxcars/vector_store/pgvector/save_to_database.rb +148 -122
- data/lib/boxcars/vector_store/pgvector/search.rb +157 -131
- data/lib/boxcars/vector_store.rb +4 -4
- data/lib/boxcars/version.rb +1 -1
- data/lib/boxcars.rb +31 -20
- metadata +11 -21
@@ -1,161 +1,244 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'openai'
|
4
|
-
|
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
|
-
|
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
|
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:
|
36
|
+
access_token = Boxcars.configuration.openai_access_token(openai_access_token:)
|
47
37
|
organization_id = Boxcars.configuration.organization_id
|
48
|
-
|
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?(
|
52
|
-
!!(
|
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
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
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
|
-
|
73
|
-
|
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
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
113
|
-
|
155
|
+
def log_messages_debug(messages)
|
156
|
+
return unless messages.is_a?(Array)
|
114
157
|
|
115
|
-
|
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(
|
121
|
-
|
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
|
-
|
124
|
-
|
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
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
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
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
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
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
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
|
-
|
158
|
-
|
159
|
-
|
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
|
-
|
3
|
+
require 'faraday'
|
4
|
+
require 'faraday/retry'
|
5
|
+
require 'json'
|
6
|
+
|
4
7
|
module Boxcars
|
5
|
-
# A engine that uses
|
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
|
-
|
10
|
-
|
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
|
-
|
16
|
-
|
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
|
25
|
+
@batch_size = batch_size # Retain if used by generate
|
26
|
+
super(description:, name:)
|
32
27
|
end
|
33
28
|
|
34
|
-
|
29
|
+
# Perplexity models are conversational.
|
30
|
+
def conversation_model?(_model_name)
|
35
31
|
true
|
36
32
|
end
|
37
33
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
116
|
-
def get_num_tokens(text:)
|
117
|
-
text.split.length # TODO: hook up to token counting gem
|
118
|
-
end
|
125
|
+
private
|
119
126
|
|
120
|
-
|
121
|
-
|
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
|
-
|
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
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
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
|
-
|
166
|
-
|
167
|
-
|
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, **
|
25
|
-
super(provider: :together_ai, description
|
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
|