Class: Riffer::Providers::Gemini

Inherits:
Base
  • Object
show all
Defined in:
lib/riffer/providers/gemini.rb,
sig/generated/riffer/providers/gemini.rbs

Overview

Google Gemini provider for Gemini models via the Gemini REST API.

Constant Summary collapse

BASE_URI =

RBS:

  • @api_key: String?

  • @open_timeout: Integer

  • @read_timeout: Integer

Returns:

  • (URI::Generic)
URI("https://generativelanguage.googleapis.com")
VALID_MODEL_PATTERN =

Signature:

  • Regexp

Returns:

  • (Regexp)
/\A[a-zA-Z0-9._-]+\z/
DEFAULT_OPEN_TIMEOUT =

Signature:

  • Integer

Returns:

  • (Integer)
10
DEFAULT_READ_TIMEOUT =

Signature:

  • Integer

Returns:

  • (Integer)
60
FINISH_REASONS =

Returns:

  • (Hash[String, Symbol])
{
  "STOP" => :stop,
  "MAX_TOKENS" => :length,
  "SAFETY" => :content_filter,
  "RECITATION" => :content_filter,
  "BLOCKLIST" => :content_filter,
  "PROHIBITED_CONTENT" => :content_filter,
  "SPII" => :content_filter,
  "IMAGE_SAFETY" => :content_filter,
  "MALFORMED_FUNCTION_CALL" => :error,
}.freeze

Constants inherited from Base

Base::REQUEST_PARAM_ATTRIBUTES, Base::WIRE_SEPARATOR

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#apply_pricing, #capture_input, #capture_messages?, #capture_output, #chat_span_attributes, #decode_tool_name, #depends_on, #encode_tool_name, #generate_text, #in_chat_span, #merge_consecutive_messages, #normalize_messages, #parse_structured_output, #parse_tool_arguments, #pricing_rates, #record_finish_reason, #record_stream_outcome, skills_adapter, #stream_text, #tag_attributes, #validate_input!, #validate_normalized_messages!, #yield_finish_reason

Constructor Details

#initialize(api_key: nil, open_timeout: nil, read_timeout: nil, **_options) ⇒ Gemini

-- : (?api_key: String?, ?open_timeout: Integer?, ?read_timeout: Integer?, **untyped) -> void

Parameters:

  • api_key: (String, nil) (defaults to: nil)
  • open_timeout: (Integer, nil) (defaults to: nil)
  • read_timeout: (Integer, nil) (defaults to: nil)
  • (Object)


41
42
43
44
45
46
47
# File 'lib/riffer/providers/gemini.rb', line 41

def initialize(api_key: nil, open_timeout: nil, read_timeout: nil, **_options)
  super()
  api_key ||= Riffer.config.gemini.api_key
  @api_key = api_key
  @open_timeout = open_timeout || Riffer.config.gemini.open_timeout || DEFAULT_OPEN_TIMEOUT
  @read_timeout = read_timeout || Riffer.config.gemini.read_timeout || DEFAULT_READ_TIMEOUT
end

Class Method Details

.semconv_provider_nameString

The GenAI semconv well-known provider name.

: () -> String

Returns:

  • (String)


35
36
37
# File 'lib/riffer/providers/gemini.rb', line 35

def self.semconv_provider_name
  "gcp.gemini"
end

Instance Method Details

#api_path(model, method) ⇒ String

-- : (String, String) -> String

Parameters:

  • (String)
  • (String)

Returns:

  • (String)


342
343
344
345
# File 'lib/riffer/providers/gemini.rb', line 342

def api_path(model, method)
  validate_model!(model)
  "v1beta/models/#{model}:#{method}"
end

#build_finish_reason(raw_reason, tool_calls:) ⇒ Riffer::Providers::FinishReason?

Gemini reports STOP even when the candidate carries functionCall parts, so tool-call presence overrides the raw value.

: (String?, tool_calls: bool) -> Riffer::Providers::FinishReason?

Parameters:

  • (String, nil)
  • tool_calls: (Boolean)

Returns:



145
146
147
148
149
150
151
152
# File 'lib/riffer/providers/gemini.rb', line 145

def build_finish_reason(raw_reason, tool_calls:)
  return nil unless raw_reason

  raw = raw_reason.to_s
  reason = FINISH_REASONS.fetch(raw, :other)
  reason = :tool_calls if reason == :stop && tool_calls
  Riffer::Providers::FinishReason.new(reason: reason, raw: raw)
end

#build_request_params(messages, model, options) ⇒ Hash[Symbol, untyped]

-- : (Array, String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


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
# File 'lib/riffer/providers/gemini.rb', line 53

def build_request_params(messages, model, options)
  partitioned = partition_messages(messages)
  tools = options[:tools]
  structured_output = options[:structured_output]

  params = {
    model: model,
    contents: partitioned[:contents],
  } #: Hash[Symbol, untyped]

  params[:systemInstruction] = partitioned[:system_instruction] if partitioned[:system_instruction]

  if tools && !tools.empty?
    params[:tools] = [{
      functionDeclarations: tools.map { |t| convert_tool_to_gemini_format(t) },
    }]
  end

  # tags propagate to observability only: the Gemini Developer API has no
  # request labels field (unknown body fields are rejected), so :tags is
  # stripped here rather than mapped. Native labels would arrive with a Vertex
  # adapter. See docs/CONFIGURATION.md.
  generation_config = options.except(:tools, :structured_output, :tags)

  if structured_output
    generation_config[:responseMimeType] = "application/json"
    generation_config[:responseSchema] = strip_additional_properties(structured_output.json_schema)
  end

  params[:generationConfig] = generation_config unless generation_config.empty?

  params
end

#build_token_usage(usage) ⇒ Riffer::Providers::TokenUsage

Gemini reports thinking tokens outside candidatesTokenCount; TokenUsage's output includes them.

: (Hash[Symbol, untyped]) -> Riffer::Providers::TokenUsage

Parameters:

  • (Hash[Symbol, untyped])

Returns:



158
159
160
161
162
163
164
165
166
# File 'lib/riffer/providers/gemini.rb', line 158

def build_token_usage(usage)
  apply_pricing(
    Riffer::Providers::TokenUsage.new(
      input_tokens: usage[:promptTokenCount] || 0,
      output_tokens: (usage[:candidatesTokenCount] || 0) + (usage[:thoughtsTokenCount] || 0),
      cache_read_tokens: usage[:cachedContentTokenCount],
    ),
  )
end

#convert_assistant_to_gemini_format(message) ⇒ Hash[Symbol, untyped]

-- : (Riffer::Messages::Assistant) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/riffer/providers/gemini.rb', line 283

def convert_assistant_to_gemini_format(message)
  parts = [] #: Array[Hash[Symbol, untyped]]
  parts << { text: message.content } if message.content && !message.content.empty?

  message.tool_calls.each do |tc|
    parts << {
      functionCall: {
        name: tc.name,
        args: parse_tool_arguments(tc.arguments),
      },
    }
  end

  { role: "model", parts: parts }
end

#convert_file_part_to_gemini_format(file) ⇒ Hash[Symbol, untyped]

-- : (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


301
302
303
304
305
306
307
308
# File 'lib/riffer/providers/gemini.rb', line 301

def convert_file_part_to_gemini_format(file)
  if file.url?
    raise Riffer::ArgumentError,
          "Gemini provider does not support URL-based file references. Provide base64-encoded data instead."
  end

  { inlineData: { mimeType: file.media_type, data: file.data } }
end

#convert_tool_to_gemini_format(tool) ⇒ Hash[Symbol, untyped]

-- : (singleton(Riffer::Tool)) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


312
313
314
315
316
317
318
# File 'lib/riffer/providers/gemini.rb', line 312

def convert_tool_to_gemini_format(tool)
  {
    name: tool.name,
    description: tool.description,
    parameters: strip_additional_properties(tool.parameters_schema),
  }
end

#encode_tool_arguments(args) ⇒ String

-- : (untyped) -> String

Parameters:

  • (Object)

Returns:

  • (String)


322
323
324
325
326
# File 'lib/riffer/providers/gemini.rb', line 322

def encode_tool_arguments(args)
  return "{}" unless args

  args.is_a?(String) ? args : args.to_json
end

#execute_generate(params) ⇒ Hash[Symbol, untyped]

-- : (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]

Parameters:

  • (Hash[Symbol, untyped])

Returns:

  • (Hash[Symbol, untyped])


89
90
91
92
93
94
95
# File 'lib/riffer/providers/gemini.rb', line 89

def execute_generate(params)
  model = params[:model]
  body = params.except(:model)
  response = post_request(api_path(model, "generateContent"), body)
  handle_api_error!(response) unless response.is_a?(Net::HTTPSuccess)
  JSON.parse(response.body, symbolize_names: true)
end

#execute_stream(params, yielder) ⇒ void

This method returns an undefined value.

-- : (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void

Parameters:



170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# File 'lib/riffer/providers/gemini.rb', line 170

def execute_stream(params, yielder)
  model = params[:model]
  body = params.except(:model)

  uri = URI("#{BASE_URI}/#{api_path(model, 'streamGenerateContent')}?alt=sse")
  host = uri.hostname #: String
  request = Net::HTTP::Post.new(uri)
  request["Content-Type"] = "application/json"
  request["x-goog-api-key"] = @api_key
  request.body = body.to_json

  full_text = +""
  buffer = +""
  raw_finish_reason = nil #: String?
  saw_function_call = false

  process_chunk = lambda do |chunk|
    buffer << chunk

    while (match = buffer.match(/\r?\n\r?\n/))
      match_end = match.end(0) #: Integer
      frame = buffer.slice!(0, match_end).to_s.strip
      next unless frame.start_with?("data: ")

      json_str = frame.delete_prefix("data: ").strip
      next if json_str.empty?

      parsed = JSON.parse(json_str, symbolize_names: true)
      parts = parsed.dig(:candidates, 0, :content, :parts)

      parts&.each do |part|
        if part[:text]
          full_text << part[:text]
          yielder << Riffer::StreamEvents::TextDelta.new(part[:text])
        elsif part[:functionCall]
          fc = part[:functionCall]
          saw_function_call = true
          call_id = "gemini_call_#{SecureRandom.hex(12)}"
          arguments = encode_tool_arguments(fc[:args])
          yielder << Riffer::StreamEvents::ToolCallDone.new(
            item_id: call_id,
            call_id: call_id,
            name: fc[:name],
            arguments: arguments,
          )
        end
      end

      raw_finish_reason = parsed.dig(:candidates, 0, :finishReason) || raw_finish_reason

      usage = parsed[:usageMetadata]
      if usage && usage[:candidatesTokenCount]
        yielder << Riffer::StreamEvents::TokenUsageDone.new(token_usage: build_token_usage(usage))
      end
    end
  end

  Net::HTTP.start(host, uri.port, use_ssl: true, open_timeout: @open_timeout, read_timeout: @read_timeout) do |http|
    http.request(request) do |response|
      handle_api_error!(response) unless response.is_a?(Net::HTTPSuccess)

      begin
        response.read_body { |chunk| process_chunk.call(chunk) }
      rescue IOError
        process_chunk.call(response.body)
      end
    end
  end

  yielder << Riffer::StreamEvents::TextDone.new(full_text) unless full_text.empty?
  yield_finish_reason(yielder, build_finish_reason(raw_finish_reason, tool_calls: saw_function_call))
end

#extract_content(response) ⇒ String

-- : (Hash[Symbol, untyped]) -> String

Parameters:

  • (Hash[Symbol, untyped])

Returns:

  • (String)


99
100
101
102
103
104
# File 'lib/riffer/providers/gemini.rb', line 99

def extract_content(response)
  parts = response.dig(:candidates, 0, :content, :parts)
  return "" unless parts

  parts.filter_map { |part| part[:text] }.join
end

#extract_finish_reason(response) ⇒ Riffer::Providers::FinishReason?

-- : (Hash[Symbol, untyped]) -> Riffer::Providers::FinishReason?

Parameters:

  • (Hash[Symbol, untyped])

Returns:



135
136
137
138
139
# File 'lib/riffer/providers/gemini.rb', line 135

def extract_finish_reason(response)
  parts = response.dig(:candidates, 0, :content, :parts)
  has_function_call = parts&.any? { |part| part[:functionCall] } || false
  build_finish_reason(response.dig(:candidates, 0, :finishReason), tool_calls: has_function_call)
end

#extract_token_usage(response) ⇒ Riffer::Providers::TokenUsage?

-- : (Hash[Symbol, untyped]) -> Riffer::Providers::TokenUsage?

Parameters:

  • (Hash[Symbol, untyped])

Returns:



126
127
128
129
130
131
# File 'lib/riffer/providers/gemini.rb', line 126

def extract_token_usage(response)
  usage = response[:usageMetadata]
  return nil unless usage

  build_token_usage(usage)
end

#extract_tool_calls(response) ⇒ Array[Riffer::Messages::Assistant::ToolCall]

-- : (Hash[Symbol, untyped]) -> Array

Parameters:

  • (Hash[Symbol, untyped])

Returns:



108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/riffer/providers/gemini.rb', line 108

def extract_tool_calls(response)
  parts = response.dig(:candidates, 0, :content, :parts)
  return [] unless parts

  parts.filter_map do |part|
    next unless part[:functionCall]

    fc = part[:functionCall]
    Riffer::Messages::Assistant::ToolCall.new(
      call_id: "gemini_call_#{SecureRandom.hex(12)}",
      name: fc[:name],
      arguments: encode_tool_arguments(fc[:args]),
    )
  end
end

#handle_api_error!(response) ⇒ void

This method returns an undefined value.

-- : (Net::HTTPResponse) -> void

Parameters:

  • (Net::HTTPResponse)


376
377
378
379
380
381
382
383
384
# File 'lib/riffer/providers/gemini.rb', line 376

def handle_api_error!(response)
  body = begin
    JSON.parse(response.body, symbolize_names: true)
  rescue JSON::ParserError
    { message: response.body }
  end
  error_message = body.dig(:error, :message) || body[:message] || response.body
  raise Riffer::Error, "Gemini API error (#{response.code}): #{error_message}"
end

#partition_messages(messages) ⇒ Hash[Symbol, untyped]

-- : (Array) -> Hash[Symbol, untyped]

Parameters:

Returns:

  • (Hash[Symbol, untyped])


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
270
271
272
273
274
275
276
277
278
279
# File 'lib/riffer/providers/gemini.rb', line 245

def partition_messages(messages)
  system_parts = [] #: Array[Hash[Symbol, untyped]]
  contents = [] #: Array[Hash[Symbol, untyped]]

  messages.each do |message|
    case message
    when Riffer::Messages::System
      system_parts << { text: message.content }
    when Riffer::Messages::User
      if message.files.empty?
        contents << { role: "user", parts: [{ text: message.content }] }
      else
        parts = [{ text: message.content }]
        message.files.each { |file| parts << convert_file_part_to_gemini_format(file) }
        contents << { role: "user", parts: parts }
      end
    when Riffer::Messages::Assistant
      contents << convert_assistant_to_gemini_format(message)
    when Riffer::Messages::Tool
      contents << {
        role: "user",
        parts: [{
          functionResponse: {
            name: message.name,
            response: { result: message.content },
          },
        }],
      }
    end
  end

  result = { contents: contents } #: Hash[Symbol, untyped]
  result[:system_instruction] = { parts: system_parts } unless system_parts.empty?
  result
end

#post_request(path, body) ⇒ Net::HTTPResponse

-- : (String, Hash[Symbol, untyped]) -> Net::HTTPResponse

Parameters:

  • (String)
  • (Hash[Symbol, untyped])

Returns:

  • (Net::HTTPResponse)


330
331
332
333
334
335
336
337
338
# File 'lib/riffer/providers/gemini.rb', line 330

def post_request(path, body)
  uri = URI("#{BASE_URI}/#{path}")
  host = uri.hostname #: String
  request = Net::HTTP::Post.new(uri)
  request["Content-Type"] = "application/json"
  request["x-goog-api-key"] = @api_key
  request.body = body.to_json
  Net::HTTP.start(host, uri.port, use_ssl: true, open_timeout: @open_timeout, read_timeout: @read_timeout) { |http| http.request(request) }
end

#strip_additional_properties(schema) ⇒ Hash[Symbol, untyped]

-- : (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]

Parameters:

  • (Hash[Symbol, untyped])

Returns:

  • (Hash[Symbol, untyped])


359
360
361
362
363
364
365
366
367
368
369
370
371
372
# File 'lib/riffer/providers/gemini.rb', line 359

def strip_additional_properties(schema)
  schema = schema.dup
  schema.delete(:additionalProperties)

  if schema[:properties]
    schema[:properties] = schema[:properties].transform_values do |prop|
      strip_additional_properties(prop)
    end
  end

  schema[:items] = strip_additional_properties(schema[:items]) if schema[:items].is_a?(Hash)

  schema
end

#validate_model!(model) ⇒ void

This method returns an undefined value.

-- : (String) -> void

Parameters:

  • (String)


349
350
351
352
353
354
355
# File 'lib/riffer/providers/gemini.rb', line 349

def validate_model!(model)
  return if model.match?(VALID_MODEL_PATTERN)

  raise Riffer::ArgumentError,
        "Invalid model name: #{model.inspect}. Model must contain only alphanumeric characters, " \
        "hyphens, dots, and underscores."
end