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.
- checksums.yaml +4 -4
- data/.rubocop.yml +6 -3
- data/.ruby-version +1 -1
- data/CHANGELOG.md +17 -0
- data/Gemfile +3 -13
- data/Gemfile.lock +30 -25
- data/POSTHOG_TEST_README.md +118 -0
- data/README.md +305 -0
- data/boxcars.gemspec +2 -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 +115 -88
- data/lib/boxcars/vector_store/pgvector/build_from_files.rb +105 -80
- data/lib/boxcars/vector_store/pgvector/save_to_database.rb +147 -122
- data/lib/boxcars/vector_store/pgvector/search.rb +156 -131
- data/lib/boxcars/vector_store.rb +4 -4
- data/lib/boxcars/version.rb +1 -1
- data/lib/boxcars.rb +31 -20
- metadata +25 -21
data/lib/boxcars/engine/groq.rb
CHANGED
@@ -1,119 +1,170 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
#
|
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
|
-
|
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
|
-
|
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
|
-
@
|
22
|
+
@groq_params = DEFAULT_PARAMS.merge(kwargs) # Corrected typo here
|
30
23
|
@prompts = prompts
|
31
24
|
@batch_size = batch_size
|
32
|
-
super(description
|
25
|
+
super(description:, name:)
|
33
26
|
end
|
34
27
|
|
35
|
-
#
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
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
|
-
|
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
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
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
|
-
|
62
|
-
|
63
|
-
Boxcars.error(e, :red)
|
64
|
-
raise
|
73
|
+
|
74
|
+
_groq_handle_call_outcome(response_data:)
|
65
75
|
end
|
66
76
|
|
67
|
-
|
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
|
-
|
73
|
-
|
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
|
-
|
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
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
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
|
-
|
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
|
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
|
34
|
-
|
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:
|
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
|
66
|
-
convo = prompt.as_intelligence_conversation(inputs:
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
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, **
|
113
|
+
def run(question, **)
|
79
114
|
prompt = Prompt.new(template: question)
|
80
|
-
response = client(prompt
|
115
|
+
response = client(prompt:, **)
|
81
116
|
extract_answer(response)
|
82
117
|
end
|
83
118
|
|
@@ -1,80 +1,160 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
#
|
3
|
+
require 'openai' # Ollama uses the OpenAI gem with a custom URI base
|
4
|
+
require 'json'
|
5
|
+
|
4
6
|
module Boxcars
|
5
|
-
# A engine that uses local
|
7
|
+
# A engine that uses a local Ollama API (OpenAI-compatible).
|
6
8
|
class Ollama < Engine
|
9
|
+
include UnifiedObservability
|
7
10
|
attr_reader :prompts, :model_kwargs, :batch_size, :ollama_params
|
8
11
|
|
9
|
-
# The default parameters to use when asking the engine.
|
10
12
|
DEFAULT_PARAMS = {
|
11
|
-
model: "llama3",
|
13
|
+
model: "llama3", # Default model for Ollama
|
12
14
|
temperature: 0.1,
|
13
|
-
max_tokens: 4096
|
15
|
+
max_tokens: 4096 # Check if Ollama respects this or has its own limits
|
14
16
|
}.freeze
|
15
|
-
|
16
|
-
# the default name of the engine
|
17
17
|
DEFAULT_NAME = "Ollama engine"
|
18
|
-
# the default description of the engine
|
19
18
|
DEFAULT_DESCRIPTION = "useful for when you need to use local 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 "OpenAI 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 2.
|
28
21
|
def initialize(name: DEFAULT_NAME, description: DEFAULT_DESCRIPTION, prompts: [], batch_size: 2, **kwargs)
|
29
22
|
@ollama_params = DEFAULT_PARAMS.merge(kwargs)
|
30
23
|
@prompts = prompts
|
31
|
-
@batch_size = batch_size
|
32
|
-
super(description
|
24
|
+
@batch_size = batch_size # Retain if used by other methods
|
25
|
+
super(description:, name:)
|
33
26
|
end
|
34
27
|
|
35
|
-
#
|
36
|
-
#
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
::OpenAI::Client.new(uri_base: "http://localhost:11434")
|
28
|
+
# Renamed from open_ai_client to ollama_client for clarity
|
29
|
+
# Ollama doesn't use an API key by default.
|
30
|
+
def self.ollama_client
|
31
|
+
# The OpenAI gem requires an access_token, even if the local service doesn't.
|
32
|
+
# Provide a dummy one if not needed, or allow configuration if Ollama setup requires one.
|
33
|
+
::OpenAI::Client.new(access_token: "ollama-dummy-key", uri_base: "http://localhost:11434/v1")
|
34
|
+
# Added /v1 to uri_base, as OpenAI-compatible endpoints often version this way.
|
35
|
+
# Verify Ollama's actual OpenAI-compatible endpoint path.
|
41
36
|
end
|
42
37
|
|
43
|
-
|
38
|
+
# Ollama models are typically conversational.
|
39
|
+
def conversation_model?(_model_name)
|
44
40
|
true
|
45
41
|
end
|
46
42
|
|
47
|
-
# Get an answer from the engine.
|
48
|
-
# @param prompt [String] The prompt to use when asking the engine.
|
49
|
-
# @param groq_api_key [String] The access token to use when asking the engine.
|
50
|
-
# Defaults to Boxcars.configuration.groq_api_key.
|
51
|
-
# @param kwargs [Hash] Additional parameters to pass to the engine if wanted.
|
52
43
|
def client(prompt:, inputs: {}, **kwargs)
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
44
|
+
start_time = Time.now
|
45
|
+
response_data = { response_obj: nil, parsed_json: nil, success: false, error: nil, status_code: nil }
|
46
|
+
current_params = @ollama_params.merge(kwargs)
|
47
|
+
current_prompt_object = prompt.is_a?(Array) ? prompt.first : prompt
|
48
|
+
api_request_params = nil # Initialize
|
49
|
+
|
50
|
+
begin
|
51
|
+
clnt = Ollama.ollama_client
|
52
|
+
api_request_params = _prepare_ollama_request_params(current_prompt_object, inputs, current_params)
|
53
|
+
|
54
|
+
log_messages_debug(api_request_params[:messages]) if Boxcars.configuration.log_prompts && api_request_params[:messages]
|
55
|
+
|
56
|
+
_execute_and_process_ollama_call(clnt, api_request_params, response_data)
|
57
|
+
rescue ::OpenAI::Error => e
|
58
|
+
_handle_openai_error_for_ollama(e, response_data)
|
59
|
+
rescue StandardError => e
|
60
|
+
_handle_standard_error_for_ollama(e, response_data)
|
61
|
+
ensure
|
62
|
+
duration_ms = ((Time.now - start_time) * 1000).round
|
63
|
+
request_context = {
|
64
|
+
prompt: current_prompt_object,
|
65
|
+
inputs:,
|
66
|
+
conversation_for_api: api_request_params&.dig(:messages)
|
67
|
+
}
|
68
|
+
track_ai_generation(
|
69
|
+
duration_ms:,
|
70
|
+
current_params:,
|
71
|
+
request_context:,
|
72
|
+
response_data:,
|
73
|
+
provider: :ollama
|
74
|
+
)
|
59
75
|
end
|
60
|
-
|
61
|
-
|
62
|
-
rescue => e
|
63
|
-
Boxcars.error(e, :red)
|
64
|
-
raise
|
76
|
+
|
77
|
+
_ollama_handle_call_outcome(response_data:)
|
65
78
|
end
|
66
79
|
|
67
|
-
|
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)
|
80
|
+
def run(question, **)
|
71
81
|
prompt = Prompt.new(template: question)
|
72
|
-
answer = client(prompt:
|
73
|
-
raise Error, "Ollama: No response from API" unless answer
|
74
|
-
|
75
|
-
# raise Error, "Ollama: #{response['error']}" if response["error"]
|
82
|
+
answer = client(prompt:, inputs: {}, **) # Pass empty inputs hash
|
76
83
|
Boxcars.debug("Answer: #{answer}", :cyan)
|
77
84
|
answer
|
78
85
|
end
|
86
|
+
|
87
|
+
def default_params
|
88
|
+
@ollama_params
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
# Helper methods for the client method
|
94
|
+
def _prepare_ollama_request_params(prompt_object, inputs, current_params)
|
95
|
+
# prompt_object.as_messages(inputs) returns a hash like { messages: [...] }
|
96
|
+
# We need to extract the array of messages for the API call.
|
97
|
+
actual_messages_array = prompt_object.as_messages(inputs)[:messages]
|
98
|
+
{ messages: actual_messages_array }.merge(current_params)
|
99
|
+
end
|
100
|
+
|
101
|
+
def _execute_and_process_ollama_call(clnt, api_request_params, response_data)
|
102
|
+
raw_response = clnt.chat(parameters: api_request_params)
|
103
|
+
response_data[:response_obj] = raw_response
|
104
|
+
response_data[:parsed_json] = raw_response # OpenAI gem returns Hash
|
105
|
+
|
106
|
+
if raw_response && !raw_response["error"] && raw_response["choices"]
|
107
|
+
response_data[:success] = true
|
108
|
+
response_data[:status_code] = 200 # Inferred for local success
|
109
|
+
else
|
110
|
+
response_data[:success] = false
|
111
|
+
err_details = raw_response["error"] if raw_response
|
112
|
+
msg = if err_details
|
113
|
+
(err_details.is_a?(Hash) ? err_details['message'] : err_details).to_s
|
114
|
+
else
|
115
|
+
"Unknown Ollama API Error"
|
116
|
+
end
|
117
|
+
response_data[:error] ||= Error.new(msg) # Use ||= to not overwrite existing exception
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def _handle_openai_error_for_ollama(error, response_data)
|
122
|
+
response_data[:error] = error
|
123
|
+
response_data[:success] = false
|
124
|
+
response_data[:status_code] = error.http_status if error.respond_to?(:http_status)
|
125
|
+
end
|
126
|
+
|
127
|
+
def _handle_standard_error_for_ollama(error, response_data)
|
128
|
+
response_data[:error] = error
|
129
|
+
response_data[:success] = false
|
130
|
+
end
|
131
|
+
|
132
|
+
def log_messages_debug(messages)
|
133
|
+
return unless messages.is_a?(Array)
|
134
|
+
|
135
|
+
Boxcars.debug(messages.last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
|
136
|
+
end
|
137
|
+
|
138
|
+
def _ollama_handle_call_outcome(response_data:)
|
139
|
+
if response_data[:error]
|
140
|
+
Boxcars.error("Ollama Error: #{response_data[:error].message} (#{response_data[:error].class.name})", :red)
|
141
|
+
raise response_data[:error] # Re-raise the original error
|
142
|
+
elsif !response_data[:success]
|
143
|
+
# This case handles errors returned in the response body but not raised as OpenAI::Error
|
144
|
+
err_details = response_data.dig(:response_obj, "error")
|
145
|
+
msg = if err_details
|
146
|
+
err_details.is_a?(Hash) ? err_details['message'] : err_details.to_s
|
147
|
+
else
|
148
|
+
"Unknown error from Ollama API"
|
149
|
+
end
|
150
|
+
raise Error, msg
|
151
|
+
else
|
152
|
+
# Extract answer from successful response (assuming OpenAI-like structure)
|
153
|
+
choices = response_data.dig(:parsed_json, "choices")
|
154
|
+
raise Error, "Ollama: No choices found in response" unless choices.is_a?(Array) && !choices.empty?
|
155
|
+
|
156
|
+
choices.map { |c| c.dig("message", "content") }.join("\n").strip
|
157
|
+
end
|
158
|
+
end
|
79
159
|
end
|
80
160
|
end
|