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
- .build_json_schema(schema) ⇒ Object
- .build_thinking_config(_model, thinking) ⇒ Object
- .build_thought_part(thinking) ⇒ Object
-
.byte_to_char_index(content, byte_index) ⇒ Object
Grounding segment indices are byte offsets into the UTF-8 response text.
- .calculate_output_tokens(data) ⇒ Object
- .chunk_citations(chunks) ⇒ Object
- .chunk_source(chunk) ⇒ Object
- .completion_url ⇒ Object
- .count_tokens_request(messages, tools:, model:, tool_prefs: nil, thinking: nil, schema: nil, citations: false, caching: nil) ⇒ Object
- .count_tokens_url ⇒ Object
- .default_large_file_upload_threshold ⇒ Object
-
.extract_citations(data, content) ⇒ Object
Normalizes grounding metadata (Google Search grounding) into citations.
- .extract_server_tool_calls(data, parts) ⇒ Object
- .extract_thought_parts(parts) ⇒ Object
- .extract_thought_signature(parts) ⇒ Object
- .finish_reasons ⇒ Object
- .format_message_parts(msg) ⇒ Object
- .format_messages(messages) ⇒ Object
- .format_parts(msg) ⇒ Object
- .format_role(role) ⇒ Object
- .format_system_instruction(messages) ⇒ Object
- .input_tokens(data) ⇒ Object
- .maybe_log_implicit_caching_note(messages, caching) ⇒ Object
- .metadata_server_tool_calls(data) ⇒ Object
- .normalize_finish_reason(reason) ⇒ Object
- .parse_completion_body(data, raw:) ⇒ Object
- .parse_content(data) ⇒ Object
- .parse_count_tokens_response(response) ⇒ Object
- .provider_file_attachable?(attachment) ⇒ Boolean
- .provider_file_upload_limit ⇒ Object
- .render_count_tokens_payload(messages, model:, **options) ⇒ Object
-
.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.
-
.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.
- .structured_output_config(schema) ⇒ Object
- .support_citations(support, chunks, content) ⇒ Object
- .supports_provider_file_references? ⇒ Boolean
- .warn_unsupported_citations(model) ⇒ Object
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_url ⇒ Object
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(, tools:, model:, tool_prefs: nil, thinking: nil, schema: nil, citations: false, caching: nil) render_payload( , 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_url ⇒ Object
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_threshold ⇒ Object
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_reasons ⇒ Object
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 (msg) parts = [] parts << build_thought_part(msg.thinking) if msg.role == :assistant && msg.thinking parts.concat(Media.format_content(msg.content, msg.)) parts end |
.format_messages(messages) ⇒ Object
150 151 152 |
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 150 def () MessageFormatter.new(self, ).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 (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() parts = .select { |msg| msg.role == :system }.flat_map do |msg| text = msg.content.to_s Media.format_content(text.empty? ? nil : text, msg.) 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(, caching) return if caching == false return unless (caching && !caching[:id]) || .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, = parse_content(data) Message.new( role: :assistant, content: content, 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
135 136 137 |
# File 'lib/ruby_llm/protocols/gemini/chat.rb', line 135 def provider_file_attachable?() .image? || .video? || .audio? || .pdf? || .text? end |
.provider_file_upload_limit ⇒ Object
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(, model:, **) request = count_tokens_request(, model: model, **) { 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(, 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: (.reject { |msg| msg.role == :system }), generationConfig: {} } system_instruction = format_system_instruction() 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(, 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.
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
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 |