Module: RubyLLM::Protocols::Gemini::Chat

Defined in:
lib/ruby_llm/protocols/gemini/chat.rb

Overview

Chat methods for the Gemini API implementation

Defined Under Namespace

Classes: MessageFormatter

Constant Summary collapse

FINISH_REASONS =
{
  'STOP' => :stop, 'MAX_TOKENS' => :max_tokens,
  'SAFETY' => :content_filter, 'RECITATION' => :content_filter, 'BLOCKLIST' => :content_filter,
  'PROHIBITED_CONTENT' => :content_filter, 'SPII' => :content_filter, 'IMAGE_SAFETY' => :content_filter,
  'IMAGE_RECITATION' => :content_filter, 'IMAGE_PROHIBITED_CONTENT' => :content_filter,
  'MODEL_ARMOR' => :content_filter
}.freeze
GEMINI_INLINE_FILE_THRESHOLD =
20 * 1024 * 1024
VERTEX_INLINE_FILE_THRESHOLD =
7 * 1024 * 1024
GEMINI_FILE_UPLOAD_LIMIT =
2 * 1024 * 1024 * 1024

Class Method Summary collapse

Class Method Details

.build_json_schema(schema) ⇒ Object



352
353
354
355
356
357
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 352

def build_json_schema(schema)
  normalized = RubyLLM::Support::Utils.deep_dup(schema[:schema])
  normalized.delete(:strict)
  normalized.delete('strict')
  RubyLLM::Support::Utils.deep_stringify_keys(normalized)
end

.build_thinking_config(_model, thinking) ⇒ Object



111
112
113
114
115
116
117
118
119
120
121
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 111

def build_thinking_config(_model, thinking)
  return { includeThoughts: false, thinkingBudget: 0 } if thinking.enabled == false

  config = { includeThoughts: true }

  config[:thinkingLevel] = thinking.effort.to_s if thinking.effort
  config[:thinkingBudget] = thinking.budget if thinking.budget.is_a?(Integer)
  config[:thinkingBudget] = -1 if thinking.enabled == true

  config
end

.build_thought_part(thinking) ⇒ Object



183
184
185
186
187
188
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 183

def build_thought_part(thinking)
  part = { thought: true }
  part[:text] = thinking.text if thinking.text
  part[:thoughtSignature] = thinking.signature if thinking.signature
  part
end

.byte_to_char_index(content, byte_index) ⇒ Object

Grounding segment indices are byte offsets into the UTF-8 response text.



322
323
324
325
326
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 322

def byte_to_char_index(content, byte_index)
  return nil unless content.is_a?(String) && byte_index

  content.byteslice(0, byte_index)&.length
end

.calculate_output_tokens(data) ⇒ Object



346
347
348
349
350
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 346

def calculate_output_tokens(data)
  candidates = data.dig('usageMetadata', 'candidatesTokenCount') || 0
  thoughts = data.dig('usageMetadata', 'thoughtsTokenCount') || 0
  candidates + thoughts
end

.chunk_citations(chunks) ⇒ Object



306
307
308
309
310
311
312
313
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 306

def chunk_citations(chunks)
  chunks.each_with_index.filter_map do |chunk, index|
    source = chunk_source(chunk)
    next unless source

    Citation.new(url: source['uri'], title: source['title'], source_index: index)
  end
end

.chunk_source(chunk) ⇒ Object



315
316
317
318
319
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 315

def chunk_source(chunk)
  return nil unless chunk.is_a?(Hash)

  chunk['web'] || chunk['retrievedContext']
end

.completion_urlObject



30
31
32
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 30

def completion_url
  "models/#{@model.id}:generateContent"
end

.count_tokens_request(messages, tools:, model:, tool_prefs: nil, thinking: nil, schema: nil, citations: false, caching: nil) ⇒ Object



92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 92

def count_tokens_request(messages, tools:, model:, tool_prefs: nil, thinking: nil, schema: nil,
                         citations: false, caching: nil)
  render_payload(
    messages,
    tools: tools,
    tool_prefs: tool_prefs,
    temperature: nil,
    model: model,
    schema: schema,
    thinking: thinking,
    citations: citations,
    caching: caching
  ).slice(:contents, :systemInstruction, :tools)
end

.count_tokens_urlObject



83
84
85
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 83

def count_tokens_url
  "models/#{@model.id}:countTokens"
end

.default_large_file_upload_thresholdObject



127
128
129
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 127

def default_large_file_upload_threshold
  @provider.slug == 'vertexai' ? VERTEX_INLINE_FILE_THRESHOLD : GEMINI_INLINE_FILE_THRESHOLD
end

.extract_citations(data, content) ⇒ Object

Normalizes grounding metadata (Google Search grounding) into citations.



275
276
277
278
279
280
281
282
283
284
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 275

def extract_citations(data, content)
   = data.dig('candidates', 0, 'groundingMetadata')
  return [] unless 

  chunks = ['groundingChunks'] || []
  supports = ['groundingSupports'] || []
  return chunk_citations(chunks) if supports.empty?

  supports.flat_map { |support| support_citations(support, chunks, content) }
end

.extract_server_tool_calls(data, parts) ⇒ Object



246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 246

def extract_server_tool_calls(data, parts)
  calls = parts.select { |part| server_tool_part?(part) }.map do |part|
    ServerToolCall.new(
      type: part.key?('executableCode') ? 'executable_code' : 'code_execution_result',
      input: part['executableCode'],
      result: part['codeExecutionResult'],
      raw: part
    )
  end
  calls.concat((data))
  calls
end

.extract_thought_parts(parts) ⇒ Object



328
329
330
331
332
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 328

def extract_thought_parts(parts)
  thought_parts = parts.select { |p| p['thought'] }
  thoughts = thought_parts.filter_map { |p| p['text'] }.join
  thoughts.empty? ? nil : thoughts
end

.extract_thought_signature(parts) ⇒ Object



334
335
336
337
338
339
340
341
342
343
344
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 334

def extract_thought_signature(parts)
  parts.each do |part|
    signature = part['thoughtSignature'] ||
                part['thought_signature'] ||
                part.dig('functionCall', 'thoughtSignature') ||
                part.dig('functionCall', 'thought_signature')
    return signature if signature
  end

  nil
end

.finish_reasonsObject



22
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 22

def finish_reasons = FINISH_REASONS

.format_message_parts(msg) ⇒ Object



174
175
176
177
178
179
180
181
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 174

def format_message_parts(msg)
  parts = []

  parts << build_thought_part(msg.thinking) if msg.role == :assistant && msg.thinking

  parts.concat(Media.format_content(msg.content, msg.attachments))
  parts
end

.format_messages(messages) ⇒ Object



150
151
152
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 150

def format_messages(messages)
  MessageFormatter.new(self, messages).format
end

.format_parts(msg) ⇒ Object



162
163
164
165
166
167
168
169
170
171
172
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 162

def format_parts(msg)
  if msg.role == :assistant && msg.raw_content
    msg.raw_content
  elsif msg.tool_call?
    format_tool_call(msg)
  elsif msg.tool_result?
    format_tool_result(msg)
  else
    format_message_parts(msg)
  end
end

.format_role(role) ⇒ Object



154
155
156
157
158
159
160
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 154

def format_role(role)
  case role
  when :assistant then 'model'
  when :system, :tool then 'user'
  else role.to_s
  end
end

.format_system_instruction(messages) ⇒ Object



141
142
143
144
145
146
147
148
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 141

def format_system_instruction(messages)
  parts = messages.select { |msg| msg.role == :system }.flat_map do |msg|
    text = msg.content.to_s
    Media.format_content(text.empty? ? nil : text, msg.attachments)
  end

  { parts: parts } if parts.any?
end

.input_tokens(data) ⇒ Object



219
220
221
222
223
224
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 219

def input_tokens(data)
  prompt_tokens = data.dig('usageMetadata', 'promptTokenCount')
  return unless prompt_tokens

  [prompt_tokens.to_i - data.dig('usageMetadata', 'cachedContentTokenCount').to_i, 0].max
end

.maybe_log_implicit_caching_note(messages, caching) ⇒ Object



64
65
66
67
68
69
70
71
72
73
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 64

def maybe_log_implicit_caching_note(messages, caching)
  return if caching == false
  return unless (caching && !caching[:id]) || messages.any?(&:cache_until_here?)

  RubyLLM.logger.debug(
    'Gemini caches repeated prompt prefixes automatically (implicit caching). ' \
    'For explicit caching, create a cache with RubyLLM.cache and attach it with ' \
    'chat.with_caching(id: cache).'
  )
end

.metadata_server_tool_calls(data) ⇒ Object



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

def (data)
  candidate = data.dig('candidates', 0) || {}
  calls = []

  queries = candidate.dig('groundingMetadata', 'webSearchQueries')
  if queries&.any?
    calls << ServerToolCall.new(type: 'google_search', input: { 'queries' => queries },
                                raw: { 'webSearchQueries' => queries })
  end

   = candidate['urlContextMetadata']
  calls << ServerToolCall.new(type: 'url_context', result: , raw: ) if 
  calls
end

.normalize_finish_reason(reason) ⇒ Object



24
25
26
27
28
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 24

def normalize_finish_reason(reason)
  return nil if reason.nil?

  finish_reasons.fetch(reason.to_s) { reason.to_s.to_sym }
end

.parse_completion_body(data, raw:) ⇒ Object



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
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 190

def parse_completion_body(data, raw:)
  parts = data.dig('candidates', 0, 'content', 'parts') || []
  tool_calls = extract_tool_calls(data)
  content, attachments = parse_content(data)

  Message.new(
    role: :assistant,
    content: content,
    attachments: attachments,
    citations: extract_citations(data, content),
    thinking: Thinking.build(
      text: extract_thought_parts(parts),
      signature: extract_thought_signature(parts)
    ),
    tool_calls: tool_calls,
    server_tool_calls: extract_server_tool_calls(data, parts),
    raw_content: parts.any? { |part| server_tool_part?(part) } ? parts : nil,
    input_tokens: input_tokens(data),
    output_tokens: calculate_output_tokens(data),
    cache_read_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'),
    thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'),
    finish_reason: normalize_finish_reason(
      data.dig('candidates', 0, 'finishReason') || data.dig('promptFeedback', 'blockReason')
    ),
    model: data['modelVersion'] || @model&.id,
    raw: raw
  )
end

.parse_content(data) ⇒ Object



226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 226

def parse_content(data)
  candidate = data.dig('candidates', 0)
  return ['', []] unless candidate

  parts = candidate.dig('content', 'parts')
  return ['', []] unless parts&.any?

  non_thought_parts = parts.reject { |part| part['thought'] }
  return ['', []] unless non_thought_parts.any?

  build_response_content(non_thought_parts)
end

.parse_count_tokens_response(response) ⇒ Object



107
108
109
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 107

def parse_count_tokens_response(response)
  response.body['totalTokens']
end

.provider_file_attachable?(attachment) ⇒ Boolean

Returns:



135
136
137
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 135

def provider_file_attachable?(attachment)
  attachment.image? || attachment.video? || attachment.audio? || attachment.pdf? || attachment.text?
end

.provider_file_upload_limitObject



131
132
133
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 131

def provider_file_upload_limit
  GEMINI_FILE_UPLOAD_LIMIT
end

.render_count_tokens_payload(messages, model:, **options) ⇒ Object



87
88
89
90
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 87

def render_count_tokens_payload(messages, model:, **options)
  request = count_tokens_request(messages, model: model, **options)
  { generateContentRequest: request.merge(model: "models/#{model.id}") }
end

.render_payload(messages, tools:, temperature:, model:, stream: false, max_output_tokens: nil, schema: nil, thinking: nil, citations: false, caching: nil, tool_prefs: nil) ⇒ Object

rubocop:disable-next Metrics/PerceivedComplexity,Lint/UnusedMethodArgument



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 35

def render_payload(messages, tools:, temperature:, model:, stream: false, max_output_tokens: nil, schema: nil,
                   thinking: nil, citations: false, caching: nil, tool_prefs: nil)
  warn_unsupported_citations(model) if citations && !model.supports?(:citations)
  tool_prefs ||= {}
  payload = {
    contents: format_messages(messages.reject { |msg| msg.role == :system }),
    generationConfig: {}
  }
  system_instruction = format_system_instruction(messages)
  payload[:systemInstruction] = system_instruction if system_instruction

  payload[:generationConfig][:temperature] = temperature unless temperature.nil?
  payload[:generationConfig][:maxOutputTokens] = max_output_tokens unless max_output_tokens.nil?

  payload[:generationConfig].merge!(structured_output_config(schema)) if schema
  payload[:generationConfig][:thinkingConfig] = build_thinking_config(model, thinking) if thinking&.enabled?

  if tools.any?
    payload[:tools] = format_tools(tools)
    # Gemini doesn't support controlling parallel tool calls
    payload[:toolConfig] = build_tool_config(tool_prefs[:choice]) unless tool_prefs[:choice].nil?
  end

  payload[:cachedContent] = cache_name(caching[:id]) if caching.is_a?(Hash) && caching[:id]
  maybe_log_implicit_caching_note(messages, caching)

  payload
end

.server_tool_part?(part) ⇒ Boolean

Code execution runs come back as parts inside the model turn and must be replayed in history; search and URL fetches come back as response-level metadata.

Returns:



242
243
244
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 242

def server_tool_part?(part)
  part.key?('executableCode') || part.key?('codeExecutionResult')
end

.structured_output_config(schema) ⇒ Object



359
360
361
362
363
364
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 359

def structured_output_config(schema)
  {
    responseMimeType: 'application/json',
    responseJsonSchema: build_json_schema(schema)
  }
end

.support_citations(support, chunks, content) ⇒ Object



286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 286

def support_citations(support, chunks, content)
  segment = support['segment'] || {}
  end_index = segment['endIndex']
  start_index = segment['startIndex'] || (0 if end_index)

  Array(support['groundingChunkIndices']).filter_map do |index|
    source = chunk_source(chunks[index])
    next unless source

    Citation.new(
      url: source['uri'],
      title: source['title'],
      text: segment['text'],
      start_index: byte_to_char_index(content, start_index),
      end_index: byte_to_char_index(content, end_index),
      source_index: index
    )
  end
end

.supports_provider_file_references?Boolean

Returns:



123
124
125
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 123

def supports_provider_file_references?
  true
end

.warn_unsupported_citations(model) ⇒ Object



75
76
77
78
79
80
81
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 75

def warn_unsupported_citations(model)
  RubyLLM.logger.warn(
    "#{model.id} does not support citations according to the model registry. " \
    'Gemini citations come from Google Search grounding: ' \
    'with_provider_options(tools: [{ google_search: {} }]).'
  )
end