ruby_llm_swarm 1.9.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 (154) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +175 -0
  4. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +187 -0
  5. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +39 -0
  6. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +24 -0
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
  8. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
  9. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
  10. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +16 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +23 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +13 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +7 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +9 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +16 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +28 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +18 -0
  22. data/lib/generators/ruby_llm/generator_helpers.rb +194 -0
  23. data/lib/generators/ruby_llm/install/install_generator.rb +106 -0
  24. data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +9 -0
  25. data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +3 -0
  26. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +7 -0
  27. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +16 -0
  28. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +45 -0
  29. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +20 -0
  30. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +12 -0
  31. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -0
  32. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
  33. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +3 -0
  34. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +145 -0
  35. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +124 -0
  36. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +15 -0
  37. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +49 -0
  38. data/lib/ruby_llm/active_record/acts_as.rb +174 -0
  39. data/lib/ruby_llm/active_record/acts_as_legacy.rb +384 -0
  40. data/lib/ruby_llm/active_record/chat_methods.rb +350 -0
  41. data/lib/ruby_llm/active_record/message_methods.rb +81 -0
  42. data/lib/ruby_llm/active_record/model_methods.rb +84 -0
  43. data/lib/ruby_llm/aliases.json +295 -0
  44. data/lib/ruby_llm/aliases.rb +38 -0
  45. data/lib/ruby_llm/attachment.rb +220 -0
  46. data/lib/ruby_llm/chat.rb +816 -0
  47. data/lib/ruby_llm/chunk.rb +6 -0
  48. data/lib/ruby_llm/configuration.rb +78 -0
  49. data/lib/ruby_llm/connection.rb +126 -0
  50. data/lib/ruby_llm/content.rb +73 -0
  51. data/lib/ruby_llm/context.rb +29 -0
  52. data/lib/ruby_llm/embedding.rb +29 -0
  53. data/lib/ruby_llm/error.rb +84 -0
  54. data/lib/ruby_llm/image.rb +49 -0
  55. data/lib/ruby_llm/message.rb +86 -0
  56. data/lib/ruby_llm/mime_type.rb +71 -0
  57. data/lib/ruby_llm/model/info.rb +111 -0
  58. data/lib/ruby_llm/model/modalities.rb +22 -0
  59. data/lib/ruby_llm/model/pricing.rb +48 -0
  60. data/lib/ruby_llm/model/pricing_category.rb +46 -0
  61. data/lib/ruby_llm/model/pricing_tier.rb +33 -0
  62. data/lib/ruby_llm/model.rb +7 -0
  63. data/lib/ruby_llm/models.json +33198 -0
  64. data/lib/ruby_llm/models.rb +231 -0
  65. data/lib/ruby_llm/models_schema.json +168 -0
  66. data/lib/ruby_llm/moderation.rb +56 -0
  67. data/lib/ruby_llm/provider.rb +243 -0
  68. data/lib/ruby_llm/providers/anthropic/capabilities.rb +134 -0
  69. data/lib/ruby_llm/providers/anthropic/chat.rb +125 -0
  70. data/lib/ruby_llm/providers/anthropic/content.rb +44 -0
  71. data/lib/ruby_llm/providers/anthropic/embeddings.rb +20 -0
  72. data/lib/ruby_llm/providers/anthropic/media.rb +92 -0
  73. data/lib/ruby_llm/providers/anthropic/models.rb +63 -0
  74. data/lib/ruby_llm/providers/anthropic/streaming.rb +45 -0
  75. data/lib/ruby_llm/providers/anthropic/tools.rb +109 -0
  76. data/lib/ruby_llm/providers/anthropic.rb +36 -0
  77. data/lib/ruby_llm/providers/bedrock/capabilities.rb +167 -0
  78. data/lib/ruby_llm/providers/bedrock/chat.rb +63 -0
  79. data/lib/ruby_llm/providers/bedrock/media.rb +61 -0
  80. data/lib/ruby_llm/providers/bedrock/models.rb +98 -0
  81. data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
  82. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +51 -0
  83. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +71 -0
  84. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +67 -0
  85. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +80 -0
  86. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +78 -0
  87. data/lib/ruby_llm/providers/bedrock/streaming.rb +18 -0
  88. data/lib/ruby_llm/providers/bedrock.rb +82 -0
  89. data/lib/ruby_llm/providers/deepseek/capabilities.rb +130 -0
  90. data/lib/ruby_llm/providers/deepseek/chat.rb +16 -0
  91. data/lib/ruby_llm/providers/deepseek.rb +30 -0
  92. data/lib/ruby_llm/providers/gemini/capabilities.rb +281 -0
  93. data/lib/ruby_llm/providers/gemini/chat.rb +454 -0
  94. data/lib/ruby_llm/providers/gemini/embeddings.rb +37 -0
  95. data/lib/ruby_llm/providers/gemini/images.rb +47 -0
  96. data/lib/ruby_llm/providers/gemini/media.rb +112 -0
  97. data/lib/ruby_llm/providers/gemini/models.rb +40 -0
  98. data/lib/ruby_llm/providers/gemini/streaming.rb +61 -0
  99. data/lib/ruby_llm/providers/gemini/tools.rb +198 -0
  100. data/lib/ruby_llm/providers/gemini/transcription.rb +116 -0
  101. data/lib/ruby_llm/providers/gemini.rb +37 -0
  102. data/lib/ruby_llm/providers/gpustack/chat.rb +27 -0
  103. data/lib/ruby_llm/providers/gpustack/media.rb +46 -0
  104. data/lib/ruby_llm/providers/gpustack/models.rb +90 -0
  105. data/lib/ruby_llm/providers/gpustack.rb +34 -0
  106. data/lib/ruby_llm/providers/mistral/capabilities.rb +155 -0
  107. data/lib/ruby_llm/providers/mistral/chat.rb +24 -0
  108. data/lib/ruby_llm/providers/mistral/embeddings.rb +33 -0
  109. data/lib/ruby_llm/providers/mistral/models.rb +48 -0
  110. data/lib/ruby_llm/providers/mistral.rb +32 -0
  111. data/lib/ruby_llm/providers/ollama/chat.rb +27 -0
  112. data/lib/ruby_llm/providers/ollama/media.rb +46 -0
  113. data/lib/ruby_llm/providers/ollama/models.rb +36 -0
  114. data/lib/ruby_llm/providers/ollama.rb +30 -0
  115. data/lib/ruby_llm/providers/openai/capabilities.rb +299 -0
  116. data/lib/ruby_llm/providers/openai/chat.rb +88 -0
  117. data/lib/ruby_llm/providers/openai/embeddings.rb +33 -0
  118. data/lib/ruby_llm/providers/openai/images.rb +38 -0
  119. data/lib/ruby_llm/providers/openai/media.rb +81 -0
  120. data/lib/ruby_llm/providers/openai/models.rb +39 -0
  121. data/lib/ruby_llm/providers/openai/moderation.rb +34 -0
  122. data/lib/ruby_llm/providers/openai/streaming.rb +46 -0
  123. data/lib/ruby_llm/providers/openai/tools.rb +98 -0
  124. data/lib/ruby_llm/providers/openai/transcription.rb +70 -0
  125. data/lib/ruby_llm/providers/openai.rb +44 -0
  126. data/lib/ruby_llm/providers/openai_responses.rb +395 -0
  127. data/lib/ruby_llm/providers/openrouter/models.rb +73 -0
  128. data/lib/ruby_llm/providers/openrouter.rb +26 -0
  129. data/lib/ruby_llm/providers/perplexity/capabilities.rb +137 -0
  130. data/lib/ruby_llm/providers/perplexity/chat.rb +16 -0
  131. data/lib/ruby_llm/providers/perplexity/models.rb +42 -0
  132. data/lib/ruby_llm/providers/perplexity.rb +48 -0
  133. data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
  134. data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
  135. data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
  136. data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
  137. data/lib/ruby_llm/providers/vertexai/transcription.rb +16 -0
  138. data/lib/ruby_llm/providers/vertexai.rb +55 -0
  139. data/lib/ruby_llm/railtie.rb +35 -0
  140. data/lib/ruby_llm/responses_session.rb +77 -0
  141. data/lib/ruby_llm/stream_accumulator.rb +101 -0
  142. data/lib/ruby_llm/streaming.rb +153 -0
  143. data/lib/ruby_llm/tool.rb +209 -0
  144. data/lib/ruby_llm/tool_call.rb +22 -0
  145. data/lib/ruby_llm/tool_executors.rb +125 -0
  146. data/lib/ruby_llm/transcription.rb +35 -0
  147. data/lib/ruby_llm/utils.rb +91 -0
  148. data/lib/ruby_llm/version.rb +5 -0
  149. data/lib/ruby_llm.rb +140 -0
  150. data/lib/tasks/models.rake +525 -0
  151. data/lib/tasks/release.rake +67 -0
  152. data/lib/tasks/ruby_llm.rake +15 -0
  153. data/lib/tasks/vcr.rake +92 -0
  154. metadata +346 -0
@@ -0,0 +1,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'rubygems/version'
5
+
6
+ module RubyLLM
7
+ module Providers
8
+ class Gemini
9
+ # Chat methods for the Gemini API implementation
10
+ module Chat
11
+ module_function
12
+
13
+ def completion_url
14
+ "models/#{@model}:generateContent"
15
+ end
16
+
17
+ def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil) # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument
18
+ @model = model.id
19
+ payload = {
20
+ contents: format_messages(messages),
21
+ generationConfig: {}
22
+ }
23
+
24
+ payload[:generationConfig][:temperature] = temperature unless temperature.nil?
25
+
26
+ payload[:generationConfig].merge!(structured_output_config(schema, model)) if schema
27
+
28
+ payload[:tools] = format_tools(tools) if tools.any?
29
+ payload
30
+ end
31
+
32
+ private
33
+
34
+ def format_messages(messages)
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
42
+ end
43
+
44
+ def format_role(role)
45
+ case role
46
+ when :assistant then 'model'
47
+ when :system then 'user'
48
+ when :tool then 'function'
49
+ else role.to_s
50
+ end
51
+ end
52
+
53
+ def format_parts(msg)
54
+ if msg.tool_call?
55
+ format_tool_call(msg)
56
+ elsif msg.tool_result?
57
+ format_tool_result(msg)
58
+ else
59
+ Media.format_content(msg.content)
60
+ end
61
+ end
62
+
63
+ def parse_completion_response(response)
64
+ data = response.body
65
+ tool_calls = extract_tool_calls(data)
66
+
67
+ Message.new(
68
+ role: :assistant,
69
+ content: parse_content(data),
70
+ tool_calls: tool_calls,
71
+ input_tokens: data.dig('usageMetadata', 'promptTokenCount'),
72
+ output_tokens: calculate_output_tokens(data),
73
+ model_id: data['modelVersion'] || response.env.url.path.split('/')[3].split(':')[0],
74
+ raw: response
75
+ )
76
+ end
77
+
78
+ def convert_schema_to_gemini(schema)
79
+ return nil unless schema
80
+
81
+ GeminiSchema.new(schema).to_h
82
+ end
83
+
84
+ def parse_content(data)
85
+ candidate = data.dig('candidates', 0)
86
+ return '' unless candidate
87
+
88
+ return '' if function_call?(candidate)
89
+
90
+ parts = candidate.dig('content', 'parts')
91
+ return '' unless parts&.any?
92
+
93
+ build_response_content(parts)
94
+ end
95
+
96
+ def function_call?(candidate)
97
+ parts = candidate.dig('content', 'parts')
98
+ parts&.any? { |p| p['functionCall'] }
99
+ end
100
+
101
+ def calculate_output_tokens(data)
102
+ candidates = data.dig('usageMetadata', 'candidatesTokenCount') || 0
103
+ thoughts = data.dig('usageMetadata', 'thoughtsTokenCount') || 0
104
+ candidates + thoughts
105
+ end
106
+
107
+ def response_json_schema_supported?(model)
108
+ version = gemini_version(model)
109
+ version && version >= Gem::Version.new('2.5')
110
+ end
111
+
112
+ def build_json_schema(schema)
113
+ normalized = RubyLLM::Utils.deep_dup(schema)
114
+ normalized.delete(:strict)
115
+ normalized.delete('strict')
116
+ RubyLLM::Utils.deep_stringify_keys(normalized)
117
+ end
118
+
119
+ def gemini_version(model)
120
+ return nil unless model
121
+
122
+ candidates = [
123
+ safe_string(model.id),
124
+ safe_string(model.respond_to?(:family) ? model.family : nil),
125
+ safe_string(model_metadata_value(model, :version)),
126
+ safe_string(model_metadata_value(model, 'version')),
127
+ safe_string(model_metadata_value(model, :description))
128
+ ].compact
129
+
130
+ candidates.each do |candidate|
131
+ version = extract_version(candidate)
132
+ return version if version
133
+ end
134
+
135
+ nil
136
+ end
137
+
138
+ def model_metadata_value(model, key)
139
+ return unless model.respond_to?(:metadata)
140
+
141
+ metadata = model.metadata
142
+ return unless metadata.is_a?(Hash)
143
+
144
+ metadata[key] || metadata[key.to_s]
145
+ end
146
+
147
+ def safe_string(value)
148
+ value&.to_s
149
+ end
150
+
151
+ def extract_version(text)
152
+ return nil unless text
153
+
154
+ match = text.match(/(\d+\.\d+|\d+)/)
155
+ return nil unless match
156
+
157
+ Gem::Version.new(match[1])
158
+ rescue ArgumentError
159
+ nil
160
+ end
161
+
162
+ def structured_output_config(schema, model)
163
+ {
164
+ responseMimeType: 'application/json'
165
+ }.tap do |config|
166
+ if response_json_schema_supported?(model)
167
+ config[:responseJsonSchema] = build_json_schema(schema)
168
+ else
169
+ config[:responseSchema] = convert_schema_to_gemini(schema)
170
+ end
171
+ end
172
+ end
173
+
174
+ # formats a message
175
+ class MessageFormatter
176
+ def initialize(messages, format_role:, format_parts:, format_tool_result:)
177
+ @messages = messages
178
+ @index = 0
179
+ @tool_call_names = {}
180
+ @format_role = format_role
181
+ @format_parts = format_parts
182
+ @format_tool_result = format_tool_result
183
+ end
184
+
185
+ def format
186
+ formatted = []
187
+
188
+ while current_message
189
+ if tool_message?(current_message)
190
+ tool_parts, next_index = collect_tool_parts
191
+ formatted << build_tool_response(tool_parts)
192
+ @index = next_index
193
+ else
194
+ remember_tool_calls if current_message.tool_call?
195
+ formatted << build_standard_message(current_message)
196
+ @index += 1
197
+ end
198
+ end
199
+
200
+ formatted
201
+ end
202
+
203
+ private
204
+
205
+ def current_message
206
+ @messages[@index]
207
+ end
208
+
209
+ def tool_message?(message)
210
+ message&.role == :tool
211
+ end
212
+
213
+ def collect_tool_parts
214
+ parts = []
215
+ index = @index
216
+
217
+ while tool_message?(@messages[index])
218
+ tool_message = @messages[index]
219
+ tool_name = @tool_call_names.delete(tool_message.tool_call_id)
220
+ parts.concat(format_tool_result(tool_message, tool_name))
221
+ index += 1
222
+ end
223
+
224
+ [parts, index]
225
+ end
226
+
227
+ def build_tool_response(parts)
228
+ { role: 'function', parts: parts }
229
+ end
230
+
231
+ def remember_tool_calls
232
+ current_message.tool_calls.each do |tool_call_id, tool_call|
233
+ @tool_call_names[tool_call_id] = tool_call.name
234
+ end
235
+ end
236
+
237
+ def build_standard_message(message)
238
+ {
239
+ role: @format_role.call(message.role),
240
+ parts: @format_parts.call(message)
241
+ }
242
+ end
243
+
244
+ def format_tool_result(message, tool_name)
245
+ @format_tool_result.call(message, tool_name)
246
+ end
247
+ end
248
+
249
+ # converts json schema to gemini
250
+ class GeminiSchema
251
+ def initialize(schema)
252
+ @raw_schema = RubyLLM::Utils.deep_dup(schema)
253
+ @definitions = {}
254
+ end
255
+
256
+ def to_h
257
+ return nil unless @raw_schema
258
+
259
+ symbolized = symbolize_and_extract_definitions(@raw_schema)
260
+ convert(symbolized, Set.new)
261
+ end
262
+
263
+ private
264
+
265
+ attr_reader :definitions
266
+
267
+ def symbolize_and_extract_definitions(value)
268
+ case value
269
+ when Hash
270
+ value.each_with_object({}) do |(key, val), hash|
271
+ key_sym = begin
272
+ key.to_sym
273
+ rescue StandardError
274
+ key
275
+ end
276
+
277
+ if definition_key?(key_sym)
278
+ merge_definitions(val)
279
+ else
280
+ hash[key_sym] = symbolize_and_extract_definitions(val)
281
+ end
282
+ end
283
+ when Array
284
+ value.map { |item| symbolize_and_extract_definitions(item) }
285
+ else
286
+ value
287
+ end
288
+ end
289
+
290
+ def definition_key?(key)
291
+ %i[$defs definitions].include?(key)
292
+ end
293
+
294
+ def merge_definitions(raw_defs)
295
+ return unless raw_defs
296
+
297
+ symbolized = symbolize_and_extract_definitions(raw_defs)
298
+ @definitions = if definitions.empty?
299
+ symbolized
300
+ else
301
+ RubyLLM::Utils.deep_merge(definitions, symbolized)
302
+ end
303
+ end
304
+
305
+ def convert(schema, visited_refs)
306
+ return default_string_schema unless schema.is_a?(Hash)
307
+
308
+ schema = strip_unsupported_keys(schema)
309
+
310
+ if schema[:$ref]
311
+ resolved = resolve_reference(schema, visited_refs)
312
+ return resolved if resolved
313
+ end
314
+
315
+ schema = normalize_any_of(schema)
316
+
317
+ result = case schema[:type].to_s
318
+ when 'object'
319
+ build_object(schema, visited_refs)
320
+ when 'array'
321
+ build_array(schema, visited_refs)
322
+ when 'number'
323
+ build_scalar('NUMBER', schema, %i[format minimum maximum enum nullable multipleOf])
324
+ when 'integer'
325
+ build_scalar('INTEGER', schema, %i[format minimum maximum enum nullable multipleOf])
326
+ when 'boolean'
327
+ build_scalar('BOOLEAN', schema, %i[nullable])
328
+ else
329
+ build_scalar('STRING', schema, %i[enum format nullable])
330
+ end
331
+
332
+ apply_description(result, schema)
333
+ result
334
+ end
335
+
336
+ def strip_unsupported_keys(schema)
337
+ schema.dup.tap do |copy|
338
+ copy.delete(:strict)
339
+ copy.delete(:additionalProperties)
340
+ end
341
+ end
342
+
343
+ def resolve_reference(schema, visited_refs)
344
+ ref = schema[:$ref]
345
+ return unless ref
346
+ return if visited_refs.include?(ref)
347
+
348
+ referenced = lookup_definition(ref)
349
+ return unless referenced
350
+
351
+ overrides = schema.except(:$ref)
352
+ visited_refs.add(ref)
353
+ merged = RubyLLM::Utils.deep_merge(referenced, overrides)
354
+ convert(merged, visited_refs)
355
+ ensure
356
+ visited_refs.delete(ref)
357
+ end
358
+
359
+ def lookup_definition(ref) # rubocop:disable Metrics/PerceivedComplexity
360
+ segments = ref.to_s.split('/').reject(&:empty?)
361
+ return nil if segments.empty?
362
+
363
+ segments.shift if segments.first == '#'
364
+ segments.shift if %w[$defs definitions].include?(segments.first)
365
+
366
+ current = definitions
367
+
368
+ segments.each do |segment|
369
+ break current = nil unless current.is_a?(Hash)
370
+
371
+ key = begin
372
+ segment.to_sym
373
+ rescue StandardError
374
+ segment
375
+ end
376
+ current = current[key]
377
+ end
378
+
379
+ current ? RubyLLM::Utils.deep_dup(current) : nil
380
+ end
381
+
382
+ def normalize_any_of(schema)
383
+ any_of = schema[:anyOf]
384
+ return schema unless any_of
385
+
386
+ options = Array(any_of).map { |option| RubyLLM::Utils.deep_symbolize_keys(option) }
387
+ nullables, non_null = options.partition { |option| schema_type(option) == 'null' }
388
+
389
+ base = RubyLLM::Utils.deep_symbolize_keys(non_null.first || { type: 'string' })
390
+ base[:nullable] = true if nullables.any?
391
+
392
+ without_any_of = schema.each_with_object({}) do |(key, value), result|
393
+ result[key] = value unless key == :anyOf
394
+ end
395
+
396
+ without_any_of.merge(base)
397
+ end
398
+
399
+ def schema_type(option)
400
+ (option[:type] || option['type']).to_s.downcase
401
+ end
402
+
403
+ def build_object(schema, visited_refs)
404
+ properties = schema.fetch(:properties, {}).transform_values do |child|
405
+ convert(child, visited_refs)
406
+ end
407
+
408
+ {
409
+ type: 'OBJECT',
410
+ properties: properties
411
+ }.tap do |object|
412
+ required = Array(schema[:required]).map(&:to_s).uniq
413
+ object[:required] = required if required.any?
414
+ object[:propertyOrdering] = schema[:propertyOrdering] if schema[:propertyOrdering]
415
+ copy_attribute(object, schema, :nullable)
416
+ end
417
+ end
418
+
419
+ def build_array(schema, visited_refs)
420
+ items_schema = schema[:items] ? convert(schema[:items], visited_refs) : default_string_schema
421
+
422
+ {
423
+ type: 'ARRAY',
424
+ items: items_schema
425
+ }.tap do |array|
426
+ copy_attribute(array, schema, :minItems)
427
+ copy_attribute(array, schema, :maxItems)
428
+ copy_attribute(array, schema, :nullable)
429
+ end
430
+ end
431
+
432
+ def build_scalar(type, schema, allowed_keys)
433
+ { type: type }.tap do |result|
434
+ allowed_keys.each { |key| copy_attribute(result, schema, key) }
435
+ end
436
+ end
437
+
438
+ def apply_description(target, schema)
439
+ description = schema[:description]
440
+ target[:description] = description if description
441
+ end
442
+
443
+ def copy_attribute(target, source, key)
444
+ target[key] = source[key] if source.key?(key)
445
+ end
446
+
447
+ def default_string_schema
448
+ { type: 'STRING' }
449
+ end
450
+ end
451
+ end
452
+ end
453
+ end
454
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Gemini
6
+ # Embeddings methods for the Gemini API integration
7
+ module Embeddings
8
+ module_function
9
+
10
+ def embedding_url(model:)
11
+ "models/#{model}:batchEmbedContents"
12
+ end
13
+
14
+ def render_embedding_payload(text, model:, dimensions:)
15
+ { requests: [text].flatten.map { |t| single_embedding_payload(t, model:, dimensions:) } }
16
+ end
17
+
18
+ def parse_embedding_response(response, model:, text:)
19
+ vectors = response.body['embeddings']&.map { |e| e['values'] }
20
+ vectors = vectors.first if vectors&.length == 1 && !text.is_a?(Array)
21
+
22
+ Embedding.new(vectors:, model:, input_tokens: 0)
23
+ end
24
+
25
+ private
26
+
27
+ def single_embedding_payload(text, model:, dimensions:)
28
+ {
29
+ model: "models/#{model}",
30
+ content: { parts: [{ text: text.to_s }] },
31
+ outputDimensionality: dimensions
32
+ }.compact
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Gemini
6
+ # Image generation methods for the Gemini API implementation
7
+ module Images
8
+ def images_url
9
+ "models/#{@model}:predict"
10
+ end
11
+
12
+ def render_image_payload(prompt, model:, size:)
13
+ RubyLLM.logger.debug "Ignoring size #{size}. Gemini does not support image size customization."
14
+ @model = model
15
+ {
16
+ instances: [
17
+ {
18
+ prompt: prompt
19
+ }
20
+ ],
21
+ parameters: {
22
+ sampleCount: 1
23
+ }
24
+ }
25
+ end
26
+
27
+ def parse_image_response(response, model:)
28
+ data = response.body
29
+ image_data = data['predictions']&.first
30
+
31
+ unless image_data&.key?('bytesBase64Encoded')
32
+ raise Error, 'Unexpected response format from Gemini image generation API'
33
+ end
34
+
35
+ mime_type = image_data['mimeType'] || 'image/png'
36
+ base64_data = image_data['bytesBase64Encoded']
37
+
38
+ Image.new(
39
+ data: base64_data,
40
+ mime_type: mime_type,
41
+ model_id: model
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Gemini # rubocop:disable Style/Documentation
6
+ # Media handling methods for the Gemini API integration
7
+ module Media
8
+ module_function
9
+
10
+ def format_content(content)
11
+ return content.value if content.is_a?(RubyLLM::Content::Raw)
12
+ return [format_text(content.to_json)] if content.is_a?(Hash) || content.is_a?(Array)
13
+ return [format_text(content)] unless content.is_a?(Content)
14
+
15
+ parts = []
16
+ parts << format_text(content.text) if content.text
17
+
18
+ content.attachments.each do |attachment|
19
+ case attachment.type
20
+ when :text
21
+ parts << format_text_file(attachment)
22
+ when :unknown
23
+ raise UnsupportedAttachmentError, attachment.mime_type
24
+ else
25
+ parts << format_attachment(attachment)
26
+ end
27
+ end
28
+
29
+ parts
30
+ end
31
+
32
+ def format_attachment(attachment)
33
+ {
34
+ inline_data: {
35
+ mime_type: attachment.mime_type,
36
+ data: attachment.encoded
37
+ }
38
+ }
39
+ end
40
+
41
+ def format_text_file(text_file)
42
+ {
43
+ text: text_file.for_llm
44
+ }
45
+ end
46
+
47
+ def format_text(text)
48
+ {
49
+ text: text
50
+ }
51
+ end
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
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Gemini
6
+ # Models methods for the Gemini API integration
7
+ module Models
8
+ module_function
9
+
10
+ def models_url
11
+ 'models'
12
+ end
13
+
14
+ def parse_list_models_response(response, slug, capabilities)
15
+ Array(response.body['models']).map do |model_data|
16
+ model_id = model_data['name'].gsub('models/', '')
17
+
18
+ Model::Info.new(
19
+ id: model_id,
20
+ name: model_data['displayName'],
21
+ provider: slug,
22
+ family: capabilities.model_family(model_id),
23
+ created_at: nil,
24
+ context_window: model_data['inputTokenLimit'] || capabilities.context_window_for(model_id),
25
+ max_output_tokens: model_data['outputTokenLimit'] || capabilities.max_tokens_for(model_id),
26
+ modalities: capabilities.modalities_for(model_id),
27
+ capabilities: capabilities.capabilities_for(model_id),
28
+ pricing: capabilities.pricing_for(model_id),
29
+ metadata: {
30
+ version: model_data['version'],
31
+ description: model_data['description'],
32
+ supported_generation_methods: model_data['supportedGenerationMethods']
33
+ }
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end