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 =

Returns:

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

: Regexp

Returns:

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

: Integer

Returns:

  • (Integer)
10
DEFAULT_READ_TIMEOUT =

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

def initialize(api_key: nil, open_timeout: nil, read_timeout: nil, **options)
  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)


339
340
341
342
# File 'lib/riffer/providers/gemini.rb', line 339

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:



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

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])


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

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/10_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:



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

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])


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

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])


298
299
300
301
302
303
304
305
# File 'lib/riffer/providers/gemini.rb', line 298

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])


309
310
311
312
313
314
315
# File 'lib/riffer/providers/gemini.rb', line 309

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)


319
320
321
322
323
# File 'lib/riffer/providers/gemini.rb', line 319

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])


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

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:



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

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)


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

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:



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

def extract_finish_reason(response)
  parts = response.dig(:candidates, 0, :content, :parts)
  has_function_call = !!parts&.any? { |part| part[:functionCall] }
  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:



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

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:



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

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)


373
374
375
376
377
378
379
380
381
# File 'lib/riffer/providers/gemini.rb', line 373

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])


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

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)


327
328
329
330
331
332
333
334
335
# File 'lib/riffer/providers/gemini.rb', line 327

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])


354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/riffer/providers/gemini.rb', line 354

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

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

  schema
end

#validate_model!(model) ⇒ void

This method returns an undefined value.

-- : (String) -> void

Parameters:

  • (String)


346
347
348
349
350
# File 'lib/riffer/providers/gemini.rb', line 346

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