ruby_llm_community 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +13 -9
  3. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +127 -67
  4. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +12 -12
  5. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +7 -7
  6. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +4 -4
  7. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +6 -6
  8. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +4 -4
  9. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +5 -5
  10. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +5 -5
  11. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +4 -4
  12. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +8 -8
  13. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +5 -5
  15. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +9 -6
  16. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +7 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +5 -5
  18. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +9 -9
  19. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +4 -6
  20. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +11 -11
  21. data/lib/generators/ruby_llm/generator_helpers.rb +152 -87
  22. data/lib/generators/ruby_llm/install/install_generator.rb +75 -79
  23. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +3 -0
  24. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +5 -0
  25. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +7 -1
  26. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +1 -1
  27. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +88 -85
  28. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +15 -0
  29. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +49 -0
  30. data/lib/ruby_llm/active_record/acts_as.rb +23 -16
  31. data/lib/ruby_llm/active_record/chat_methods.rb +41 -13
  32. data/lib/ruby_llm/active_record/message_methods.rb +11 -2
  33. data/lib/ruby_llm/active_record/model_methods.rb +1 -1
  34. data/lib/ruby_llm/aliases.json +61 -32
  35. data/lib/ruby_llm/attachment.rb +42 -11
  36. data/lib/ruby_llm/chat.rb +13 -2
  37. data/lib/ruby_llm/configuration.rb +6 -1
  38. data/lib/ruby_llm/connection.rb +4 -4
  39. data/lib/ruby_llm/content.rb +23 -0
  40. data/lib/ruby_llm/message.rb +17 -9
  41. data/lib/ruby_llm/model/info.rb +4 -0
  42. data/lib/ruby_llm/models.json +7157 -6089
  43. data/lib/ruby_llm/models.rb +14 -22
  44. data/lib/ruby_llm/provider.rb +27 -5
  45. data/lib/ruby_llm/providers/anthropic/chat.rb +18 -5
  46. data/lib/ruby_llm/providers/anthropic/content.rb +44 -0
  47. data/lib/ruby_llm/providers/anthropic/media.rb +6 -5
  48. data/lib/ruby_llm/providers/anthropic/models.rb +9 -2
  49. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -18
  50. data/lib/ruby_llm/providers/bedrock/media.rb +2 -1
  51. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +9 -2
  52. data/lib/ruby_llm/providers/gemini/chat.rb +353 -72
  53. data/lib/ruby_llm/providers/gemini/media.rb +59 -1
  54. data/lib/ruby_llm/providers/gemini/tools.rb +146 -25
  55. data/lib/ruby_llm/providers/gemini/transcription.rb +116 -0
  56. data/lib/ruby_llm/providers/gemini.rb +2 -1
  57. data/lib/ruby_llm/providers/gpustack/media.rb +1 -0
  58. data/lib/ruby_llm/providers/ollama/media.rb +1 -0
  59. data/lib/ruby_llm/providers/openai/capabilities.rb +15 -7
  60. data/lib/ruby_llm/providers/openai/chat.rb +7 -3
  61. data/lib/ruby_llm/providers/openai/media.rb +2 -1
  62. data/lib/ruby_llm/providers/openai/streaming.rb +7 -3
  63. data/lib/ruby_llm/providers/openai/tools.rb +34 -12
  64. data/lib/ruby_llm/providers/openai/transcription.rb +70 -0
  65. data/lib/ruby_llm/providers/openai_base.rb +1 -0
  66. data/lib/ruby_llm/providers/vertexai/transcription.rb +16 -0
  67. data/lib/ruby_llm/providers/vertexai.rb +11 -11
  68. data/lib/ruby_llm/railtie.rb +24 -22
  69. data/lib/ruby_llm/stream_accumulator.rb +8 -12
  70. data/lib/ruby_llm/tool.rb +126 -0
  71. data/lib/ruby_llm/transcription.rb +35 -0
  72. data/lib/ruby_llm/utils.rb +46 -0
  73. data/lib/ruby_llm/version.rb +1 -1
  74. data/lib/ruby_llm_community.rb +7 -1
  75. metadata +27 -3
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+ require 'rubygems/version'
5
+
3
6
  module RubyLLM
4
7
  module Providers
5
8
  class Gemini
@@ -15,17 +18,12 @@ module RubyLLM
15
18
  @model = model.id
16
19
  payload = {
17
20
  contents: format_messages(messages),
18
- generationConfig: {
19
- responseModalities: capabilities.modalities_for(model.id)[:output]
20
- }
21
+ generationConfig: {}
21
22
  }
22
23
 
23
24
  payload[:generationConfig][:temperature] = temperature unless temperature.nil?
24
25
 
25
- if schema
26
- payload[:generationConfig][:responseMimeType] = 'application/json'
27
- payload[:generationConfig][:responseSchema] = convert_schema_to_gemini(schema)
28
- end
26
+ payload[:generationConfig].merge!(structured_output_config(schema, model)) if schema
29
27
 
30
28
  payload[:tools] = format_tools(tools) if tools.any?
31
29
  payload
@@ -34,40 +32,29 @@ module RubyLLM
34
32
  private
35
33
 
36
34
  def format_messages(messages)
37
- messages.map do |msg|
38
- {
39
- role: format_role(msg.role),
40
- parts: format_parts(msg)
41
- }
42
- end
35
+ formatter = MessageFormatter.new(
36
+ messages,
37
+ format_role: method(:format_role),
38
+ format_parts: method(:format_parts),
39
+ format_tool_result: method(:format_tool_result)
40
+ )
41
+ formatter.format
43
42
  end
44
43
 
45
44
  def format_role(role)
46
45
  case role
47
46
  when :assistant then 'model'
48
- when :system, :tool then 'user'
47
+ when :system then 'user'
48
+ when :tool then 'function'
49
49
  else role.to_s
50
50
  end
51
51
  end
52
52
 
53
53
  def format_parts(msg)
54
54
  if msg.tool_call?
55
- [{
56
- functionCall: {
57
- name: msg.tool_calls.values.first.name,
58
- args: msg.tool_calls.values.first.arguments
59
- }
60
- }]
55
+ format_tool_call(msg)
61
56
  elsif msg.tool_result?
62
- [{
63
- functionResponse: {
64
- name: msg.tool_call_id,
65
- response: {
66
- name: msg.tool_call_id,
67
- content: Media.format_content(msg.content)
68
- }
69
- }
70
- }]
57
+ format_tool_result(msg)
71
58
  else
72
59
  Media.format_content(msg.content)
73
60
  end
@@ -79,7 +66,7 @@ module RubyLLM
79
66
 
80
67
  Message.new(
81
68
  role: :assistant,
82
- content: extract_content(data),
69
+ content: parse_content(data),
83
70
  tool_calls: tool_calls,
84
71
  input_tokens: data.dig('usageMetadata', 'promptTokenCount'),
85
72
  output_tokens: calculate_output_tokens(data),
@@ -92,23 +79,19 @@ module RubyLLM
92
79
  def convert_schema_to_gemini(schema)
93
80
  return nil unless schema
94
81
 
95
- build_base_schema(schema).tap do |result|
96
- result[:description] = schema[:description] if schema[:description]
97
- apply_type_specific_attributes(result, schema)
98
- end
82
+ GeminiSchema.new(schema).to_h
99
83
  end
100
84
 
101
- def extract_content(data)
85
+ def parse_content(data)
102
86
  candidate = data.dig('candidates', 0)
103
87
  return '' unless candidate
104
88
 
105
89
  return '' if function_call?(candidate)
106
90
 
107
91
  parts = candidate.dig('content', 'parts')
108
- text_parts = parts&.select { |p| p['text'] }
109
- return '' unless text_parts&.any?
92
+ return '' unless parts&.any?
110
93
 
111
- text_parts.map { |p| p['text'] }.join
94
+ build_response_content(parts)
112
95
  end
113
96
 
114
97
  def function_call?(candidate)
@@ -122,50 +105,348 @@ module RubyLLM
122
105
  candidates + thoughts
123
106
  end
124
107
 
125
- def build_base_schema(schema)
126
- case schema[:type]
127
- when 'object'
128
- build_object_schema(schema)
129
- when 'array'
130
- { type: 'ARRAY', items: schema[:items] ? convert_schema_to_gemini(schema[:items]) : { type: 'STRING' } }
131
- when 'number'
132
- { type: 'NUMBER' }
133
- when 'integer'
134
- { type: 'INTEGER' }
135
- when 'boolean'
136
- { type: 'BOOLEAN' }
137
- else
138
- { type: 'STRING' }
108
+ def response_json_schema_supported?(model)
109
+ version = gemini_version(model)
110
+ version && version >= Gem::Version.new('2.5')
111
+ end
112
+
113
+ def build_json_schema(schema)
114
+ normalized = RubyLLM::Utils.deep_dup(schema)
115
+ normalized.delete(:strict)
116
+ normalized.delete('strict')
117
+ RubyLLM::Utils.deep_stringify_keys(normalized)
118
+ end
119
+
120
+ def gemini_version(model)
121
+ return nil unless model
122
+
123
+ candidates = [
124
+ safe_string(model.id),
125
+ safe_string(model.respond_to?(:family) ? model.family : nil),
126
+ safe_string(model_metadata_value(model, :version)),
127
+ safe_string(model_metadata_value(model, 'version')),
128
+ safe_string(model_metadata_value(model, :description))
129
+ ].compact
130
+
131
+ candidates.each do |candidate|
132
+ version = extract_version(candidate)
133
+ return version if version
139
134
  end
135
+
136
+ nil
137
+ end
138
+
139
+ def model_metadata_value(model, key)
140
+ return unless model.respond_to?(:metadata)
141
+
142
+ metadata = model.metadata
143
+ return unless metadata.is_a?(Hash)
144
+
145
+ metadata[key] || metadata[key.to_s]
146
+ end
147
+
148
+ def safe_string(value)
149
+ value&.to_s
150
+ end
151
+
152
+ def extract_version(text)
153
+ return nil unless text
154
+
155
+ match = text.match(/(\d+\.\d+|\d+)/)
156
+ return nil unless match
157
+
158
+ Gem::Version.new(match[1])
159
+ rescue ArgumentError
160
+ nil
140
161
  end
141
162
 
142
- def build_object_schema(schema)
163
+ def structured_output_config(schema, model)
143
164
  {
144
- type: 'OBJECT',
145
- properties: (schema[:properties] || {}).transform_values { |prop| convert_schema_to_gemini(prop) },
146
- required: schema[:required] || []
147
- }.tap do |object|
148
- object[:propertyOrdering] = schema[:propertyOrdering] if schema[:propertyOrdering]
149
- object[:nullable] = schema[:nullable] if schema.key?(:nullable)
165
+ responseMimeType: 'application/json'
166
+ }.tap do |config|
167
+ if response_json_schema_supported?(model)
168
+ config[:responseJsonSchema] = build_json_schema(schema)
169
+ else
170
+ config[:responseSchema] = convert_schema_to_gemini(schema)
171
+ end
150
172
  end
151
173
  end
152
174
 
153
- def apply_type_specific_attributes(result, schema)
154
- case schema[:type]
155
- when 'string'
156
- copy_attributes(result, schema, :enum, :format, :nullable)
157
- when 'number', 'integer'
158
- copy_attributes(result, schema, :format, :minimum, :maximum, :enum, :nullable)
159
- when 'array'
160
- copy_attributes(result, schema, :minItems, :maxItems, :nullable)
161
- when 'boolean'
162
- copy_attributes(result, schema, :nullable)
175
+ # formats a message
176
+ class MessageFormatter
177
+ def initialize(messages, format_role:, format_parts:, format_tool_result:)
178
+ @messages = messages
179
+ @index = 0
180
+ @tool_call_names = {}
181
+ @format_role = format_role
182
+ @format_parts = format_parts
183
+ @format_tool_result = format_tool_result
184
+ end
185
+
186
+ def format
187
+ formatted = []
188
+
189
+ while current_message
190
+ if tool_message?(current_message)
191
+ tool_parts, next_index = collect_tool_parts
192
+ formatted << build_tool_response(tool_parts)
193
+ @index = next_index
194
+ else
195
+ remember_tool_calls if current_message.tool_call?
196
+ formatted << build_standard_message(current_message)
197
+ @index += 1
198
+ end
199
+ end
200
+
201
+ formatted
202
+ end
203
+
204
+ private
205
+
206
+ def current_message
207
+ @messages[@index]
208
+ end
209
+
210
+ def tool_message?(message)
211
+ message&.role == :tool
212
+ end
213
+
214
+ def collect_tool_parts
215
+ parts = []
216
+ index = @index
217
+
218
+ while tool_message?(@messages[index])
219
+ tool_message = @messages[index]
220
+ tool_name = @tool_call_names.delete(tool_message.tool_call_id)
221
+ parts.concat(format_tool_result(tool_message, tool_name))
222
+ index += 1
223
+ end
224
+
225
+ [parts, index]
226
+ end
227
+
228
+ def build_tool_response(parts)
229
+ { role: 'function', parts: parts }
230
+ end
231
+
232
+ def remember_tool_calls
233
+ current_message.tool_calls.each do |tool_call_id, tool_call|
234
+ @tool_call_names[tool_call_id] = tool_call.name
235
+ end
236
+ end
237
+
238
+ def build_standard_message(message)
239
+ {
240
+ role: @format_role.call(message.role),
241
+ parts: @format_parts.call(message)
242
+ }
243
+ end
244
+
245
+ def format_tool_result(message, tool_name)
246
+ @format_tool_result.call(message, tool_name)
163
247
  end
164
248
  end
165
249
 
166
- def copy_attributes(target, source, *attributes)
167
- attributes.each do |attr|
168
- target[attr] = source[attr] if attr == :nullable ? source.key?(attr) : source[attr]
250
+ # converts json schema to gemini
251
+ class GeminiSchema
252
+ def initialize(schema)
253
+ @raw_schema = RubyLLM::Utils.deep_dup(schema)
254
+ @definitions = {}
255
+ end
256
+
257
+ def to_h
258
+ return nil unless @raw_schema
259
+
260
+ symbolized = symbolize_and_extract_definitions(@raw_schema)
261
+ convert(symbolized, Set.new)
262
+ end
263
+
264
+ private
265
+
266
+ attr_reader :definitions
267
+
268
+ def symbolize_and_extract_definitions(value)
269
+ case value
270
+ when Hash
271
+ value.each_with_object({}) do |(key, val), hash|
272
+ key_sym = begin
273
+ key.to_sym
274
+ rescue StandardError
275
+ key
276
+ end
277
+
278
+ if definition_key?(key_sym)
279
+ merge_definitions(val)
280
+ else
281
+ hash[key_sym] = symbolize_and_extract_definitions(val)
282
+ end
283
+ end
284
+ when Array
285
+ value.map { |item| symbolize_and_extract_definitions(item) }
286
+ else
287
+ value
288
+ end
289
+ end
290
+
291
+ def definition_key?(key)
292
+ %i[$defs definitions].include?(key)
293
+ end
294
+
295
+ def merge_definitions(raw_defs)
296
+ return unless raw_defs
297
+
298
+ symbolized = symbolize_and_extract_definitions(raw_defs)
299
+ @definitions = if definitions.empty?
300
+ symbolized
301
+ else
302
+ RubyLLM::Utils.deep_merge(definitions, symbolized)
303
+ end
304
+ end
305
+
306
+ def convert(schema, visited_refs)
307
+ return default_string_schema unless schema.is_a?(Hash)
308
+
309
+ schema = strip_unsupported_keys(schema)
310
+
311
+ if schema[:$ref]
312
+ resolved = resolve_reference(schema, visited_refs)
313
+ return resolved if resolved
314
+ end
315
+
316
+ schema = normalize_any_of(schema)
317
+
318
+ result = case schema[:type].to_s
319
+ when 'object'
320
+ build_object(schema, visited_refs)
321
+ when 'array'
322
+ build_array(schema, visited_refs)
323
+ when 'number'
324
+ build_scalar('NUMBER', schema, %i[format minimum maximum enum nullable multipleOf])
325
+ when 'integer'
326
+ build_scalar('INTEGER', schema, %i[format minimum maximum enum nullable multipleOf])
327
+ when 'boolean'
328
+ build_scalar('BOOLEAN', schema, %i[nullable])
329
+ else
330
+ build_scalar('STRING', schema, %i[enum format nullable])
331
+ end
332
+
333
+ apply_description(result, schema)
334
+ result
335
+ end
336
+
337
+ def strip_unsupported_keys(schema)
338
+ schema.dup.tap do |copy|
339
+ copy.delete(:strict)
340
+ copy.delete(:additionalProperties)
341
+ end
342
+ end
343
+
344
+ def resolve_reference(schema, visited_refs)
345
+ ref = schema[:$ref]
346
+ return unless ref
347
+ return if visited_refs.include?(ref)
348
+
349
+ referenced = lookup_definition(ref)
350
+ return unless referenced
351
+
352
+ overrides = schema.except(:$ref)
353
+ visited_refs.add(ref)
354
+ merged = RubyLLM::Utils.deep_merge(referenced, overrides)
355
+ convert(merged, visited_refs)
356
+ ensure
357
+ visited_refs.delete(ref)
358
+ end
359
+
360
+ def lookup_definition(ref) # rubocop:disable Metrics/PerceivedComplexity
361
+ segments = ref.to_s.split('/').reject(&:empty?)
362
+ return nil if segments.empty?
363
+
364
+ segments.shift if segments.first == '#'
365
+ segments.shift if %w[$defs definitions].include?(segments.first)
366
+
367
+ current = definitions
368
+
369
+ segments.each do |segment|
370
+ break current = nil unless current.is_a?(Hash)
371
+
372
+ key = begin
373
+ segment.to_sym
374
+ rescue StandardError
375
+ segment
376
+ end
377
+ current = current[key]
378
+ end
379
+
380
+ current ? RubyLLM::Utils.deep_dup(current) : nil
381
+ end
382
+
383
+ def normalize_any_of(schema)
384
+ any_of = schema[:anyOf]
385
+ return schema unless any_of
386
+
387
+ options = Array(any_of).map { |option| RubyLLM::Utils.deep_symbolize_keys(option) }
388
+ nullables, non_null = options.partition { |option| schema_type(option) == 'null' }
389
+
390
+ base = RubyLLM::Utils.deep_symbolize_keys(non_null.first || { type: 'string' })
391
+ base[:nullable] = true if nullables.any?
392
+
393
+ without_any_of = schema.each_with_object({}) do |(key, value), result|
394
+ result[key] = value unless key == :anyOf
395
+ end
396
+
397
+ without_any_of.merge(base)
398
+ end
399
+
400
+ def schema_type(option)
401
+ (option[:type] || option['type']).to_s.downcase
402
+ end
403
+
404
+ def build_object(schema, visited_refs)
405
+ properties = schema.fetch(:properties, {}).transform_values do |child|
406
+ convert(child, visited_refs)
407
+ end
408
+
409
+ {
410
+ type: 'OBJECT',
411
+ properties: properties
412
+ }.tap do |object|
413
+ required = Array(schema[:required]).map(&:to_s).uniq
414
+ object[:required] = required if required.any?
415
+ object[:propertyOrdering] = schema[:propertyOrdering] if schema[:propertyOrdering]
416
+ copy_attribute(object, schema, :nullable)
417
+ end
418
+ end
419
+
420
+ def build_array(schema, visited_refs)
421
+ items_schema = schema[:items] ? convert(schema[:items], visited_refs) : default_string_schema
422
+
423
+ {
424
+ type: 'ARRAY',
425
+ items: items_schema
426
+ }.tap do |array|
427
+ copy_attribute(array, schema, :minItems)
428
+ copy_attribute(array, schema, :maxItems)
429
+ copy_attribute(array, schema, :nullable)
430
+ end
431
+ end
432
+
433
+ def build_scalar(type, schema, allowed_keys)
434
+ { type: type }.tap do |result|
435
+ allowed_keys.each { |key| copy_attribute(result, schema, key) }
436
+ end
437
+ end
438
+
439
+ def apply_description(target, schema)
440
+ description = schema[:description]
441
+ target[:description] = description if description
442
+ end
443
+
444
+ def copy_attribute(target, source, key)
445
+ target[key] = source[key] if source.key?(key)
446
+ end
447
+
448
+ def default_string_schema
449
+ { type: 'STRING' }
169
450
  end
170
451
  end
171
452
  end
@@ -2,12 +2,13 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Providers
5
- class Gemini
5
+ class Gemini # rubocop:disable Style/Documentation
6
6
  # Media handling methods for the Gemini API integration
7
7
  module Media
8
8
  module_function
9
9
 
10
10
  def format_content(content)
11
+ return content.value if content.is_a?(RubyLLM::Content::Raw)
11
12
  return [format_text(content.to_json)] if content.is_a?(Hash) || content.is_a?(Array)
12
13
  return [format_text(content)] unless content.is_a?(Content)
13
14
 
@@ -49,6 +50,63 @@ module RubyLLM
49
50
  }
50
51
  end
51
52
  end
53
+
54
+ def build_response_content(parts) # rubocop:disable Metrics/PerceivedComplexity
55
+ text = []
56
+ attachments = []
57
+
58
+ parts.each_with_index do |part, index|
59
+ if part['text']
60
+ text << part['text']
61
+ elsif part['inlineData']
62
+ attachment = build_inline_attachment(part['inlineData'], index)
63
+ attachments << attachment if attachment
64
+ elsif part['fileData']
65
+ attachment = build_file_attachment(part['fileData'], index)
66
+ attachments << attachment if attachment
67
+ end
68
+ end
69
+
70
+ text = text.join
71
+ text = nil if text.empty?
72
+ return text if attachments.empty?
73
+
74
+ Content.new(text:, attachments:)
75
+ end
76
+
77
+ def build_inline_attachment(inline_data, index)
78
+ encoded = inline_data['data']
79
+ return unless encoded
80
+
81
+ mime_type = inline_data['mimeType']
82
+ decoded = Base64.decode64(encoded)
83
+ io = StringIO.new(decoded)
84
+ io.set_encoding(Encoding::BINARY) if io.respond_to?(:set_encoding)
85
+
86
+ filename = attachment_filename(mime_type, index)
87
+ RubyLLM::Attachment.new(io, filename:)
88
+ rescue ArgumentError => e
89
+ RubyLLM.logger.warn "Failed to decode Gemini inline data attachment: #{e.message}"
90
+ nil
91
+ end
92
+
93
+ def build_file_attachment(file_data, index)
94
+ uri = file_data['fileUri']
95
+ return unless uri
96
+
97
+ filename = file_data['filename'] || attachment_filename(file_data['mimeType'], index)
98
+ RubyLLM::Attachment.new(uri, filename:)
99
+ end
100
+
101
+ def attachment_filename(mime_type, index)
102
+ return "gemini_attachment_#{index + 1}" unless mime_type
103
+
104
+ extension = mime_type.split('/').last.to_s
105
+ extension = 'jpg' if extension == 'jpeg'
106
+ extension = 'txt' if extension == 'plain'
107
+ extension = extension.tr('+', '.')
108
+ "gemini_attachment_#{index + 1}.#{extension}"
109
+ end
52
110
  end
53
111
  end
54
112
  end