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,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
 
@@ -1,80 +1,160 @@
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' # Ollama uses the OpenAI gem with a custom URI base
4
+ require 'json'
5
+
4
6
  module Boxcars
5
- # A engine that uses local GPT4All API.
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: description, name: name)
24
+ @batch_size = batch_size # Retain if used by other methods
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
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
- def conversation_model?(_model)
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
- clnt = Ollama.open_ai_client
54
- params = ollama_params.merge(kwargs)
55
- prompt = prompt.first if prompt.is_a?(Array)
56
- params = prompt.as_messages(inputs).merge(params)
57
- if Boxcars.configuration.log_prompts
58
- Boxcars.debug(params[:messages].last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
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
- ans = clnt.chat(parameters: params)
61
- ans['choices'].pluck('message').pluck('content').join("\n")
62
- rescue => e
63
- Boxcars.error(e, :red)
64
- raise
76
+
77
+ _ollama_handle_call_outcome(response_data:)
65
78
  end
66
79
 
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)
80
+ def run(question, **)
71
81
  prompt = Prompt.new(template: question)
72
- answer = client(prompt: prompt, **kwargs)
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