Class: Riffer::Providers::Gemini

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

Overview

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

Defined Under Namespace

Classes: Client

Constant Summary collapse

VALID_MODEL_PATTERN =

Signature:

  • Regexp

Returns:

  • (Regexp)
/\A[a-zA-Z0-9._-]+\z/
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

Class Method Details

.semconv_provider_nameString

The GenAI semconv well-known provider name.

: () -> String

Returns:

  • (String)


26
27
28
# File 'lib/riffer/providers/gemini.rb', line 26

def self.semconv_provider_name
  "gcp.gemini"
end

Instance Method Details

#api_path(model, method) ⇒ String

-- : (String, String) -> String

Parameters:

  • (String)
  • (String)

Returns:

  • (String)


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

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

#build_clientObject

-- : () -> untyped

Returns:

  • (Object)


40
41
42
# File 'lib/riffer/providers/gemini.rb', line 40

def build_client
  Riffer::Providers::Gemini::Client.new(**{ api_key: Riffer.config.gemini.api_key }.compact)
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:



136
137
138
139
140
141
142
143
# File 'lib/riffer/providers/gemini.rb', line 136

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


46
47
48
49
50
51
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
# File 'lib/riffer/providers/gemini.rb', line 46

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:



149
150
151
152
153
154
155
156
157
# File 'lib/riffer/providers/gemini.rb', line 149

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

#clientRiffer::Providers::Gemini::Client



6
# File 'sig/_private/riffer/providers/gemini.rbs', line 6

def client: () -> Riffer::Providers::Gemini::Client

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

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

Parameters:

Returns:

  • (Hash[Symbol, untyped])


258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/riffer/providers/gemini.rb', line 258

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


276
277
278
279
280
281
282
283
# File 'lib/riffer/providers/gemini.rb', line 276

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


287
288
289
290
291
292
293
# File 'lib/riffer/providers/gemini.rb', line 287

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)


297
298
299
300
301
# File 'lib/riffer/providers/gemini.rb', line 297

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


82
83
84
85
86
# File 'lib/riffer/providers/gemini.rb', line 82

def execute_generate(params)
  model = params[:model]
  body = params.except(:model)
  client.post(api_path(model, "generateContent"), body)
end

#execute_stream(params, yielder) ⇒ void

This method returns an undefined value.

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

Parameters:



161
162
163
164
165
166
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
# File 'lib/riffer/providers/gemini.rb', line 161

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

  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

  path = "#{api_path(model, 'streamGenerateContent')}?alt=sse"
  client.post_stream(path, body) { |chunk| process_chunk.call(chunk) }

  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)


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

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:



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

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:



117
118
119
120
121
122
# File 'lib/riffer/providers/gemini.rb', line 117

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:



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/riffer/providers/gemini.rb', line 99

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

#global_clientObject

-- : () -> untyped

Returns:

  • (Object)


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

def global_client
  Riffer.config.gemini.client
end

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

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

Parameters:

Returns:

  • (Hash[Symbol, untyped])


220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# File 'lib/riffer/providers/gemini.rb', line 220

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

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

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

Parameters:

  • (Hash[Symbol, untyped])

Returns:

  • (Hash[Symbol, untyped])


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

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)


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

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