Class: Boxcars::Engine Abstract

Inherits:
Object
  • Object
show all
Defined in:
lib/boxcars/engine.rb

Overview

This class is abstract.

Instance Attribute Summary collapse

Instance Method Summary collapse

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

Parameters:

  • name (String) (defaults to: nil)

    The name of the Engine. Defaults to classname.

  • description (String) (defaults to: 'Engine')

    A description of the Engine.

  • batch_size (Integer) (defaults to: 20)

    The number of prompts to send to the Engine at a time.

  • user_id (String, Integer) (defaults to: nil)

    The ID of the user using this Engine (optional for observability).



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_sizeObject (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_idObject (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

#capabilitiesObject

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.

Parameters:

  • prompts (Array<Array(Prompt, Hash)>)

    Prompt/input pairs to run.

  • stop (Array<String>, nil) (defaults to: nil)

    Optional stop words.

Returns:



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.

Parameters:

  • prompt (Prompt)

    The prompt object to run.

  • inputs (Hash) (defaults to: {})

    Input values for prompt interpolation.

  • stop (Array<String>, nil) (defaults to: nil)

    Optional stop words.

Returns:



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

Parameters:

  • sub_choices (Array<Hash>)

    The choices to get generation info for.

Returns:

  • (Array<Generation>)

    The generation information.



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.

Parameters:

  • question (String)

    The question to ask 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

Returns:

  • (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

Parameters:

  • response (Hash)

    The response to validate.

  • must_haves (Array<String>) (defaults to: %w[choices]))

    The keys that must be in the response.

Raises:



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]
      message = 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: #{message}"
    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