Class: Boxcars::Engine Abstract
- Inherits:
-
Object
- Object
- Boxcars::Engine
- Defined in:
- lib/boxcars/engine.rb
Overview
Direct Known Subclasses
Anthropic, Cohere, GeminiAi, Gpt4allEng, Groq, IntelligenceBase, Ollama, Openai, Perplexityai
Instance Attribute Summary collapse
-
#batch_size ⇒ Object
readonly
Returns the value of attribute batch_size.
-
#user_id ⇒ Object
readonly
Returns the value of attribute user_id.
Instance Method Summary collapse
- #add_usage_detail!(token_usage_details, key, value) ⇒ Object
- #aggregate_generate_usage!(api_response_hash:, token_usage:, token_usage_details:, raw_usage:, inkeys:) ⇒ Object
- #aggregate_token_usage_details!(token_usage_details:, api_usage:) ⇒ Object
- #append_generate_choices!(choices:, api_response_hash:) ⇒ Object
-
#capabilities ⇒ Object
Provider/runtime capabilities used by newer execution strategies (e.g. tool-calling planners and structured-output helpers).
- #extract_answer(response) ⇒ Object
-
#generate(prompts:, stop: nil) ⇒ EngineResult
Call out to the LLM endpoint with one or more prompt/input pairs.
-
#generate_one(prompt:, inputs: {}, stop: nil) ⇒ EngineResult
Generate one completion result from a single prompt/input pair.
-
#generation_info(sub_choices) ⇒ Array<Generation>
Get generation informaton.
-
#get_num_tokens(text:) ⇒ Object
calculate the number of tokens used.
-
#initialize(description: 'Engine', name: nil, batch_size: 20, user_id: nil) ⇒ Engine
constructor
An Engine is used by Boxcars to generate output from prompts.
- #normalize_generate_response(value) ⇒ Object
- #process_generate_prompt!(prompt:, inputs:, stop:, choices:, token_usage:, token_usage_details:, raw_usage:, inkeys:) ⇒ Object
- #process_generate_response!(api_response_hash:, choices:, token_usage:, token_usage_details:, raw_usage:, inkeys:) ⇒ Object
-
#run(question) ⇒ Object
Get an answer from the Engine.
- #supports?(capability) ⇒ Boolean
- #usage_nested_token_value(usage_hash, parent_key, key) ⇒ Object
- #usage_token_value(usage_hash, key) ⇒ Object
-
#validate_response!(response, must_haves: %w[choices])) ⇒ Object
Validate API response and raise appropriate errors.
Constructor Details
#initialize(description: 'Engine', name: nil, batch_size: 20, user_id: nil) ⇒ Engine
An Engine is used by Boxcars to generate output from prompts
13 14 15 16 17 18 |
# File 'lib/boxcars/engine.rb', line 13 def initialize(description: 'Engine', name: nil, batch_size: 20, user_id: nil) @name = name || self.class.name @description = description @batch_size = batch_size @user_id = user_id end |
Instance Attribute Details
#batch_size ⇒ Object (readonly)
Returns the value of attribute batch_size.
6 7 8 |
# File 'lib/boxcars/engine.rb', line 6 def batch_size @batch_size end |
#user_id ⇒ Object (readonly)
Returns the value of attribute user_id.
6 7 8 |
# File 'lib/boxcars/engine.rb', line 6 def user_id @user_id end |
Instance Method Details
#add_usage_detail!(token_usage_details, key, value) ⇒ Object
197 198 199 200 201 |
# File 'lib/boxcars/engine.rb', line 197 def add_usage_detail!(token_usage_details, key, value) return if value.nil? token_usage_details[key] = token_usage_details.fetch(key, 0) + value.to_i end |
#aggregate_generate_usage!(api_response_hash:, token_usage:, token_usage_details:, raw_usage:, inkeys:) ⇒ Object
165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/boxcars/engine.rb', line 165 def aggregate_generate_usage!(api_response_hash:, token_usage:, token_usage_details:, raw_usage:, inkeys:) api_usage = api_response_hash["usage"] unless api_usage.is_a?(Hash) Boxcars.logger&.warn "No 'usage' data found in API response: #{api_response_hash.inspect}" return end raw_usage << api_usage.dup usage_keys = inkeys & api_usage.keys usage_keys.each { |key| token_usage[key] = token_usage[key].to_i + api_usage[key] } aggregate_token_usage_details!(token_usage_details:, api_usage:) end |
#aggregate_token_usage_details!(token_usage_details:, api_usage:) ⇒ Object
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'lib/boxcars/engine.rb', line 178 def aggregate_token_usage_details!(token_usage_details:, api_usage:) input_tokens = usage_token_value(api_usage, "input_tokens") || usage_token_value(api_usage, "prompt_tokens") output_tokens = usage_token_value(api_usage, "output_tokens") || usage_token_value(api_usage, "completion_tokens") total_tokens = usage_token_value(api_usage, "total_tokens") total_tokens ||= input_tokens + output_tokens if input_tokens && output_tokens add_usage_detail!(token_usage_details, :input_tokens, input_tokens) add_usage_detail!(token_usage_details, :output_tokens, output_tokens) add_usage_detail!(token_usage_details, :total_tokens, total_tokens) cached_input_tokens = usage_nested_token_value(api_usage, "input_tokens_details", "cached_tokens") cached_input_tokens ||= usage_nested_token_value(api_usage, "prompt_tokens_details", "cached_tokens") add_usage_detail!(token_usage_details, :cached_input_tokens, cached_input_tokens) return unless input_tokens && !cached_input_tokens.nil? add_usage_detail!(token_usage_details, :uncached_input_tokens, [input_tokens - cached_input_tokens, 0].max) end |
#append_generate_choices!(choices:, api_response_hash:) ⇒ Object
145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/boxcars/engine.rb', line 145 def append_generate_choices!(choices:, api_response_hash:) current_choices = api_response_hash["choices"] if current_choices.is_a?(Array) choices.concat(current_choices) elsif api_response_hash["output"] # Synthesize a choice from non-Chat providers (e.g., OpenAI Responses API for GPT-5) synthesized_text = extract_answer(api_response_hash) choices << { "message" => { "content" => synthesized_text }, "finish_reason" => "stop" } elsif api_response_hash["completion"] choices << { "text" => api_response_hash["completion"], "finish_reason" => api_response_hash["stop_reason"] } elsif api_response_hash["text"] choices << { "text" => api_response_hash["text"], "finish_reason" => api_response_hash["finish_reason"] } else Boxcars.logger&.warn "No generation content found in API response: #{api_response_hash.inspect}" end end |
#capabilities ⇒ Object
Provider/runtime capabilities used by newer execution strategies (e.g. tool-calling planners and structured-output helpers). Engines can override this to advertise support.
33 34 35 36 37 38 39 40 |
# File 'lib/boxcars/engine.rb', line 33 def capabilities { tool_calling: false, structured_output_json_schema: false, native_json_object: false, responses_api: false } end |
#extract_answer(response) ⇒ Object
214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/boxcars/engine.rb', line 214 def extract_answer(response) # Handle different response formats if response["choices"] response["choices"].map { |c| c.dig("message", "content") || c["text"] }.join("\n").strip elsif response["candidates"] response["candidates"].map { |c| c.dig("content", "parts", 0, "text") }.join("\n").strip elsif response["completion"] response["completion"].to_s else response["output"] || response.to_s end end |
#generate(prompts:, stop: nil) ⇒ EngineResult
Call out to the LLM endpoint with one or more prompt/input pairs.
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
# File 'lib/boxcars/engine.rb', line 79 def generate(prompts:, stop: nil) generations = [] token_usage = {} token_usage_details = {} raw_usage = [] # Get the token usage from the response. # Includes prompt, completion, and total tokens used. inkeys = %w[completion_tokens prompt_tokens total_tokens].freeze prompts.each_slice(batch_size) do |sub_prompts| sub_prompts.each do |sprompt, inputs| prompt_choices = [] process_generate_prompt!( prompt: sprompt, inputs:, stop:, choices: prompt_choices, token_usage:, token_usage_details:, raw_usage:, inkeys: ) generations << generation_info(prompt_choices) end end EngineResult.new(generations:, engine_output: { token_usage:, token_usage_details:, raw_usage: }) end |
#generate_one(prompt:, inputs: {}, stop: nil) ⇒ EngineResult
Generate one completion result from a single prompt/input pair.
71 72 73 |
# File 'lib/boxcars/engine.rb', line 71 def generate_one(prompt:, inputs: {}, stop: nil) generate(prompts: [[prompt, inputs]], stop:) end |
#generation_info(sub_choices) ⇒ Array<Generation>
Get generation informaton
54 55 56 57 58 59 60 61 62 63 64 |
# File 'lib/boxcars/engine.rb', line 54 def generation_info(sub_choices) sub_choices.map do |choice| Generation.new( text: (choice.dig("message", "content") || choice["text"]).to_s, generation_info: { finish_reason: choice.fetch("finish_reason", nil), logprobs: choice.fetch("logprobs", nil) } ) end end |
#get_num_tokens(text:) ⇒ Object
calculate the number of tokens used
47 48 49 |
# File 'lib/boxcars/engine.rb', line 47 def get_num_tokens(text:) text.split.length # TODO: hook up to token counting gem end |
#normalize_generate_response(value) ⇒ Object
131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/boxcars/engine.rb', line 131 def normalize_generate_response(value) case value when Hash value.each_with_object({}) do |(key, nested), out| normalized_key = key.is_a?(Symbol) ? key.to_s : key out[normalized_key] = normalize_generate_response(nested) end when Array value.map { |nested| normalize_generate_response(nested) } else value end end |
#process_generate_prompt!(prompt:, inputs:, stop:, choices:, token_usage:, token_usage_details:, raw_usage:, inkeys:) ⇒ Object
107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/boxcars/engine.rb', line 107 def process_generate_prompt!(prompt:, inputs:, stop:, choices:, token_usage:, token_usage_details:, raw_usage:, inkeys:) params = {} params[:stop] = stop if stop process_generate_response!( api_response_hash: client(prompt:, inputs:, **params), choices:, token_usage:, token_usage_details:, raw_usage:, inkeys: ) end |
#process_generate_response!(api_response_hash:, choices:, token_usage:, token_usage_details:, raw_usage:, inkeys:) ⇒ Object
120 121 122 123 124 125 126 127 128 129 |
# File 'lib/boxcars/engine.rb', line 120 def process_generate_response!(api_response_hash:, choices:, token_usage:, token_usage_details:, raw_usage:, inkeys:) normalized_response = normalize_generate_response(api_response_hash) unless normalized_response.is_a?(Hash) raise TypeError, "Expected Hash from client method, got #{api_response_hash.class}: #{api_response_hash.inspect}" end validate_response!(normalized_response) append_generate_choices!(choices:, api_response_hash: normalized_response) aggregate_generate_usage!(api_response_hash: normalized_response, token_usage:, token_usage_details:, raw_usage:, inkeys:) end |
#run(question) ⇒ Object
Get an answer from the Engine.
22 23 24 25 26 27 28 |
# File 'lib/boxcars/engine.rb', line 22 def run(question, **) prompt = Prompt.new(template: question) response = client(prompt:, inputs: {}, **) answer = extract_answer(response) Boxcars.debug("Answer: #{answer}", :cyan) answer end |
#supports?(capability) ⇒ Boolean
42 43 44 |
# File 'lib/boxcars/engine.rb', line 42 def supports?(capability) capabilities.fetch(capability.to_sym, false) end |
#usage_nested_token_value(usage_hash, parent_key, key) ⇒ Object
207 208 209 210 211 212 |
# File 'lib/boxcars/engine.rb', line 207 def usage_nested_token_value(usage_hash, parent_key, key) parent = usage_hash[parent_key] || usage_hash[parent_key.to_sym] return nil unless parent.is_a?(Hash) parent[key] || parent[key.to_sym] end |
#usage_token_value(usage_hash, key) ⇒ Object
203 204 205 |
# File 'lib/boxcars/engine.rb', line 203 def usage_token_value(usage_hash, key) usage_hash[key] || usage_hash[key.to_sym] end |
#validate_response!(response, must_haves: %w[choices])) ⇒ Object
Validate API response and raise appropriate errors
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 |
# File 'lib/boxcars/engine.rb', line 232 def validate_response!(response, must_haves: %w[choices]) # Check for API errors first if response['error'] error_details = response['error'] raise Boxcars::Error, "API error: #{error_details}" unless error_details.is_a?(Hash) # Some SDK response objects serialize `error: null` as a hash with nil values. # Treat that as no error and continue validating required response content. has_error_content = error_details.any? do |_k, v| if v.is_a?(Hash) v.values.any? { |nested| !(nested.nil? || (nested.respond_to?(:empty?) && nested.empty?)) } elsif v.is_a?(Array) !v.empty? else !(v.nil? || (v.respond_to?(:empty?) && v.empty?)) end end return if !has_error_content && must_haves.any? { |key| response.key?(key) && !response[key].nil? } if has_error_content # No actual error payload; continue to required key checks below. code = error_details['code'] || error_details[:code] = error_details['message'] || error_details[:message] || 'unknown error' # Handle common API key errors raise KeyError, "API key not valid or permission denied" if ['invalid_api_key', 'permission_denied'].include?(code) raise Boxcars::Error, "API error: #{}" end end # Check for required keys in response has_required_content = must_haves.any? { |key| response.key?(key) && !response[key].nil? } return if has_required_content raise Boxcars::Error, "Response missing required keys. Expected one of: #{must_haves.join(', ')}" end |