boxcars 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -3
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +41 -0
  5. data/Gemfile +3 -13
  6. data/Gemfile.lock +29 -25
  7. data/POSTHOG_TEST_README.md +118 -0
  8. data/README.md +305 -0
  9. data/boxcars.gemspec +1 -2
  10. data/lib/boxcars/boxcar/active_record.rb +9 -10
  11. data/lib/boxcars/boxcar/calculator.rb +2 -2
  12. data/lib/boxcars/boxcar/engine_boxcar.rb +4 -4
  13. data/lib/boxcars/boxcar/google_search.rb +2 -2
  14. data/lib/boxcars/boxcar/json_engine_boxcar.rb +1 -1
  15. data/lib/boxcars/boxcar/ruby_calculator.rb +1 -1
  16. data/lib/boxcars/boxcar/sql_base.rb +4 -4
  17. data/lib/boxcars/boxcar/swagger.rb +3 -3
  18. data/lib/boxcars/boxcar/vector_answer.rb +3 -3
  19. data/lib/boxcars/boxcar/xml_engine_boxcar.rb +1 -1
  20. data/lib/boxcars/boxcar.rb +6 -6
  21. data/lib/boxcars/conversation_prompt.rb +3 -3
  22. data/lib/boxcars/engine/anthropic.rb +121 -23
  23. data/lib/boxcars/engine/cerebras.rb +2 -2
  24. data/lib/boxcars/engine/cohere.rb +135 -9
  25. data/lib/boxcars/engine/gemini_ai.rb +151 -76
  26. data/lib/boxcars/engine/google.rb +2 -2
  27. data/lib/boxcars/engine/gpt4all_eng.rb +92 -34
  28. data/lib/boxcars/engine/groq.rb +124 -73
  29. data/lib/boxcars/engine/intelligence_base.rb +52 -17
  30. data/lib/boxcars/engine/ollama.rb +127 -47
  31. data/lib/boxcars/engine/openai.rb +186 -103
  32. data/lib/boxcars/engine/perplexityai.rb +116 -136
  33. data/lib/boxcars/engine/together.rb +2 -2
  34. data/lib/boxcars/engine/unified_observability.rb +430 -0
  35. data/lib/boxcars/engine.rb +4 -3
  36. data/lib/boxcars/engines.rb +74 -0
  37. data/lib/boxcars/observability.rb +44 -0
  38. data/lib/boxcars/observability_backend.rb +17 -0
  39. data/lib/boxcars/observability_backends/multi_backend.rb +42 -0
  40. data/lib/boxcars/observability_backends/posthog_backend.rb +89 -0
  41. data/lib/boxcars/observation.rb +8 -8
  42. data/lib/boxcars/prompt.rb +16 -4
  43. data/lib/boxcars/result.rb +7 -12
  44. data/lib/boxcars/ruby_repl.rb +1 -1
  45. data/lib/boxcars/train/train_action.rb +1 -1
  46. data/lib/boxcars/train/xml_train.rb +3 -3
  47. data/lib/boxcars/train/xml_zero_shot.rb +1 -1
  48. data/lib/boxcars/train/zero_shot.rb +3 -3
  49. data/lib/boxcars/train.rb +1 -1
  50. data/lib/boxcars/vector_search.rb +5 -5
  51. data/lib/boxcars/vector_store/pgvector/build_from_array.rb +116 -88
  52. data/lib/boxcars/vector_store/pgvector/build_from_files.rb +106 -80
  53. data/lib/boxcars/vector_store/pgvector/save_to_database.rb +148 -122
  54. data/lib/boxcars/vector_store/pgvector/search.rb +157 -131
  55. data/lib/boxcars/vector_store.rb +4 -4
  56. data/lib/boxcars/version.rb +1 -1
  57. data/lib/boxcars.rb +31 -20
  58. metadata +11 -21
@@ -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
@@ -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
154
+
155
+ def log_messages_debug(messages)
156
+ return unless messages.is_a?(Array)
157
+
158
+ Boxcars.debug(messages.last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
159
+ end
160
+
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)
111
171
 
112
- raise ValueError, "OpenAI error: #{msg}"
172
+ # Handle models like o1-mini that don't support the system role
173
+ if formatted_params[:model] =~ /^o/ && formatted_params[:messages].first&.fetch(:role)&.to_s == 'system'
174
+ formatted_params[:messages].first[:role] = :user
175
+ end
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)
113
184
  end
185
+ formatted_params
186
+ end
114
187
 
115
- must_haves.each do |key|
116
- raise ValueError, "Expecting key #{key} in response" unless response.key?(key)
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}"
117
192
  end
193
+ Boxcars.error("OpenAI Error: #{detailed_error_message} (#{error_data.class.name})", :red)
194
+ raise error_data
118
195
  end
119
- end
120
196
 
121
- # the engine type
122
- def engine_type
123
- "openai"
124
- 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
125
202
 
126
- # lookup the context size for a model by name
127
- # @param modelname [String] The name of the model to lookup.
128
- def modelname_to_contextsize(modelname)
129
- model_lookup = {
130
- 'text-davinci-003': 4097,
131
- 'text-curie-001': 2048,
132
- 'text-babbage-001': 2048,
133
- 'text-ada-001': 2048,
134
- 'code-davinci-002': 8000,
135
- 'code-cushman-001': 2048,
136
- 'gpt-3.5-turbo-1': 4096
137
- }.freeze
138
- model_lookup[modelname] || 4097
139
- end
203
+ def _extract_openai_answer_from_choices(choices)
204
+ raise Error, "OpenAI: No choices found in response" unless choices.is_a?(Array) && !choices.empty?
140
205
 
141
- # Calculate the maximum number of tokens possible to generate for a prompt.
142
- # @param prompt_text [String] The prompt text to use.
143
- # @return [Integer] the number of tokens possible to generate.
144
- def max_tokens_for_prompt(prompt_text)
145
- num_tokens = get_num_tokens(prompt_text)
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
146
214
 
147
- # get max context size for model by name
148
- max_size = modelname_to_contextsize(model_name)
149
- max_size - num_tokens
150
- end
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
151
224
 
152
- def get_params(prompt, inputs, params)
153
- params = prompt.as_messages(inputs).merge(params)
154
- # Handle models like o1-mini that don't support the system role
155
- params[:messages].first[:role] = :user if params[:model] =~ /^o/ && params[:messages].first&.fetch(:role) == :system
156
- if Boxcars.configuration.log_prompts
157
- Boxcars.debug(params[:messages].last(2).map { |p| ">>>>>> Role: #{p[:role]} <<<<<<\n#{p[:content]}" }.join("\n"), :cyan)
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
+ )
158
242
  end
159
- params
160
243
  end
161
244
  end