Class: Spectre::Openai::Completions

Inherits:
Object
  • Object
show all
Defined in:
lib/spectre/openai/completions.rb

Constant Summary collapse

API_URL =
'https://api.openai.com/v1/chat/completions'
DEFAULT_MODEL =
'gpt-4o-mini'
DEFAULT_TIMEOUT =
60

Class Method Summary collapse

Class Method Details

.create(messages:, model: DEFAULT_MODEL, json_schema: nil, tools: nil, **args) ⇒ Hash

Class method to generate a completion based on user messages and optional tools

Parameters:

  • messages (Array<Hash>)

    The conversation messages, each with a role and content

  • model (String) (defaults to: DEFAULT_MODEL)

    The model to be used for generating completions, defaults to DEFAULT_MODEL

  • json_schema (Hash, nil) (defaults to: nil)

    An optional JSON schema to enforce structured output

  • tools (Array<Hash>, nil) (defaults to: nil)

    An optional array of tool definitions for function calling

  • args (Hash, nil)

    optional arguments like read_timeout and open_timeout. Provide max_tokens at the top level only. Any additional kwargs (e.g., temperature:, top_p:) will be forwarded into the request body.

Returns:

  • (Hash)

    The parsed response including any function calls or content

Raises:

  • (APIKeyNotConfiguredError)

    If the API key is not set

  • (RuntimeError)

    For general API errors or unexpected issues



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/spectre/openai/completions.rb', line 25

def self.create(messages:, model: DEFAULT_MODEL, json_schema: nil, tools: nil, **args)
  api_key = Spectre.openai_configuration.api_key
  raise APIKeyNotConfiguredError, "API key is not configured" unless api_key

  validate_messages!(messages)

  uri = URI(API_URL)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  http.read_timeout = args.fetch(:read_timeout, DEFAULT_TIMEOUT)
  http.open_timeout = args.fetch(:open_timeout, DEFAULT_TIMEOUT)

  request = Net::HTTP::Post.new(uri.path, {
    'Content-Type' => 'application/json',
    'Authorization' => "Bearer #{api_key}"
  })

  max_tokens = args[:max_tokens]
  # Forward extra args (like temperature) into the body, excluding control/network keys
  forwarded = args.reject { |k, _| [:read_timeout, :open_timeout, :max_tokens].include?(k) }
  request.body = generate_body(messages, model, json_schema, max_tokens, tools, forwarded).to_json
  response = http.request(request)

  unless response.is_a?(Net::HTTPSuccess)
    raise "OpenAI API Error: #{response.code} - #{response.message}: #{response.body}"
  end

  parsed_response = JSON.parse(response.body)

  handle_response(parsed_response)
rescue JSON::ParserError => e
  raise "JSON Parse Error: #{e.message}"
end

.generate_body(messages, model, json_schema, max_tokens, tools, forwarded) ⇒ Hash

Helper method to generate the request body

Parameters:

  • messages (Array<Hash>)

    The conversation messages, each with a role and content

  • model (String)

    The model to be used for generating completions

  • json_schema (Hash, nil)

    An optional JSON schema to enforce structured output

  • max_tokens (Integer, nil)

    The maximum number of tokens for the completion

  • tools (Array<Hash>, nil)

    An optional array of tool definitions for function calling

Returns:

  • (Hash)

    The body for the API request



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/spectre/openai/completions.rb', line 88

def self.generate_body(messages, model, json_schema, max_tokens, tools, forwarded)
  body = {
    model: model,
    messages: messages
  }

  body[:max_tokens] = max_tokens if max_tokens
  body[:response_format] = { type: 'json_schema', json_schema: json_schema } if json_schema
  body[:tools] = tools if tools # Add the tools to the request body if provided

  # Merge any extra forwarded options (e.g., temperature, top_p)
  if forwarded && !forwarded.empty?
    body.merge!(forwarded.transform_keys(&:to_sym))
  end

  body
end

.handle_response(response) ⇒ Hash

Handles the API response, raising errors for specific cases and returning structured content otherwise

Parameters:

  • response (Hash)

    The parsed API response

Returns:

  • (Hash)

    The relevant data based on the finish reason



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/spectre/openai/completions.rb', line 110

def self.handle_response(response)
  message = response.dig('choices', 0, 'message')
  finish_reason = response.dig('choices', 0, 'finish_reason')

  # Check if the response contains a refusal
  if message['refusal']
    raise "Refusal: #{message['refusal']}"
  end

  # Check if the finish reason is "length", indicating incomplete response
  if finish_reason == "length"
    raise "Incomplete response: The completion was cut off due to token limit."
  end

  # Check if the finish reason is "content_filter", indicating policy violations
  if finish_reason == "content_filter"
    raise "Content filtered: The model's output was blocked due to policy violations."
  end

  # Check if the model made a function call
  if finish_reason == "function_call" || finish_reason == "tool_calls"
    return { tool_calls: message['tool_calls'], content: message['content'] }
  end

  # If the response finished normally, return the content
  if finish_reason == "stop"
    return { content: message['content'] }
  end

  # Handle unexpected finish reasons
  raise "Unexpected finish_reason: #{finish_reason}"
end

.validate_messages!(messages) ⇒ Object

Validate the structure and content of the messages array.

Parameters:

  • messages (Array<Hash>)

    The array of message hashes to validate.

Raises:

  • (ArgumentError)

    if the messages array is not in the expected format or contains invalid data.



66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/spectre/openai/completions.rb', line 66

def self.validate_messages!(messages)
  # Check if messages is an array of hashes.
  # This ensures that the input is in the correct format for message processing.
  unless messages.is_a?(Array) && messages.all? { |msg| msg.is_a?(Hash) }
    raise ArgumentError, "Messages must be an array of message hashes."
  end

  # Check if the array is empty.
  # This prevents requests with no messages, which would be invalid.
  if messages.empty?
    raise ArgumentError, "Messages cannot be empty."
  end
end