Class: Spectre::Gemini::Completions

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

Constant Summary collapse

API_URL =

Using Google’s OpenAI-compatible endpoint

'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions'
DEFAULT_MODEL =
'gemini-2.5-flash'
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

Raises:

  • (APIKeyNotConfiguredError)

    If the API key is not set

  • (RuntimeError)

    For general API errors or unexpected issues



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
58
# File 'lib/spectre/gemini/completions.rb', line 26

def self.create(messages:, model: DEFAULT_MODEL, json_schema: nil, tools: nil, **args)
  api_key = Spectre.gemini_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 "Gemini 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) ⇒ Object

Helper method to generate the request body (OpenAI-compatible)



81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/spectre/gemini/completions.rb', line 81

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

  # 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) ⇒ Object

Handles the API response, mirroring OpenAI semantics



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/spectre/gemini/completions.rb', line 100

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

  if message && message['refusal']
    raise "Refusal: #{message['refusal']}"
  end

  if finish_reason == 'length'
    raise "Incomplete response: The completion was cut off due to token limit."
  end

  if finish_reason == 'content_filter'
    raise "Content filtered: The model's output was blocked due to policy violations."
  end

  if finish_reason == 'function_call' || finish_reason == 'tool_calls'
    return { tool_calls: message['tool_calls'], content: message['content'] }
  end

  if finish_reason == 'stop'
    return { content: message['content'] }
  end

  raise "Unexpected finish_reason: #{finish_reason}"
end

.validate_messages!(messages) ⇒ Object

Validate the structure and content of the messages array.



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

def self.validate_messages!(messages)
  unless messages.is_a?(Array) && messages.all? { |msg| msg.is_a?(Hash) }
    raise ArgumentError, "Messages must be an array of message hashes."
  end

  if messages.empty?
    raise ArgumentError, "Messages cannot be empty."
  end

  # Gemini's OpenAI-compatible chat endpoint requires that single-turn
  # and general requests end with a user message. If not, return a clear error.
  last_role = (messages.last[:role] || messages.last['role']).to_s
  unless last_role == 'user'
    raise ArgumentError, "Gemini: the last message must have role 'user'. Got '#{last_role}'."
  end
end