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, prompts: [], 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.

  • prompts (Array<Prompt>) (defaults to: [])

    The prompts to use for 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).



14
15
16
17
18
19
20
# File 'lib/boxcars/engine.rb', line 14

def initialize(description: 'Engine', name: nil, prompts: [], batch_size: 20, user_id: nil)
  @name = name || self.class.name
  @description = description
  @prompts = prompts
  @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

#promptsObject (readonly)

Returns the value of attribute prompts.



6
7
8
# File 'lib/boxcars/engine.rb', line 6

def prompts
  @prompts
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

#extract_answer(response) ⇒ Object



105
106
107
108
109
110
111
112
113
114
# File 'lib/boxcars/engine.rb', line 105

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
  else
    response["output"] || response.to_s
  end
end

#generate(prompts:, stop: nil) ⇒ EngineResult

Call out to LLM’s endpoint with k unique prompts.

Parameters:

  • prompts (Array<String>)

    The prompts to pass into the model.

  • inputs (Array<String>)

    The inputs to subsitite into the prompt.

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

    Optional list of stop words to use when generating.

Returns:



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
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
# File 'lib/boxcars/engine.rb', line 53

def generate(prompts:, stop: nil)
  params = {}
  params[:stop] = stop if stop
  choices = []
  token_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|
      client_response = client(prompt: sprompt, inputs:, **params)

      # All engines now return the parsed API response hash directly
      api_response_hash = client_response

      # Ensure we have a hash to work with
      unless api_response_hash.is_a?(Hash)
        raise TypeError, "Expected Hash from client method, got #{api_response_hash.class}: #{api_response_hash.inspect}"
      end

      validate_response!(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" }
      else
        Boxcars.logger&.warn "No 'choices' or 'output' found in API response: #{api_response_hash.inspect}"
      end

      api_usage = api_response_hash["usage"]
      if api_usage.is_a?(Hash)
        usage_keys = inkeys & api_usage.keys
        usage_keys.each { |key| token_usage[key] = token_usage[key].to_i + api_usage[key] }
      else
        Boxcars.logger&.warn "No 'usage' data found in API response: #{api_response_hash.inspect}"
      end
    end
  end

  n = params.fetch(:n, 1)
  generations = []
  prompts.each_with_index do |_prompt, i|
    sub_choices = choices[i * n, (i + 1) * n]
    generations.push(generation_info(sub_choices))
  end
  EngineResult.new(generations:, engine_output: { token_usage: })
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.



36
37
38
39
40
41
42
43
44
45
46
# File 'lib/boxcars/engine.rb', line 36

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



29
30
31
# File 'lib/boxcars/engine.rb', line 29

def get_num_tokens(text:)
  text.split.length # TODO: hook up to token counting gem
end

#run(question) ⇒ Object

Get an answer from the Engine.

Parameters:

  • question (String)

    The question to ask the Engine.

Raises:

  • (NotImplementedError)


24
25
26
# File 'lib/boxcars/engine.rb', line 24

def run(question)
  raise NotImplementedError
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:



121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/boxcars/engine.rb', line 121

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)

    code = error_details['code']
    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

  # 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