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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +175 -0
- data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +187 -0
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +39 -0
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +24 -0
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
- data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +16 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +23 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +13 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +7 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +9 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +16 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +28 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +18 -0
- data/lib/generators/ruby_llm/generator_helpers.rb +194 -0
- data/lib/generators/ruby_llm/install/install_generator.rb +106 -0
- data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +9 -0
- data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +7 -0
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +16 -0
- data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +45 -0
- data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +20 -0
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +12 -0
- data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -0
- data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +145 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +124 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +15 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +49 -0
- data/lib/ruby_llm/active_record/acts_as.rb +174 -0
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +384 -0
- data/lib/ruby_llm/active_record/chat_methods.rb +350 -0
- data/lib/ruby_llm/active_record/message_methods.rb +81 -0
- data/lib/ruby_llm/active_record/model_methods.rb +84 -0
- data/lib/ruby_llm/aliases.json +295 -0
- data/lib/ruby_llm/aliases.rb +38 -0
- data/lib/ruby_llm/attachment.rb +220 -0
- data/lib/ruby_llm/chat.rb +816 -0
- data/lib/ruby_llm/chunk.rb +6 -0
- data/lib/ruby_llm/configuration.rb +78 -0
- data/lib/ruby_llm/connection.rb +126 -0
- data/lib/ruby_llm/content.rb +73 -0
- data/lib/ruby_llm/context.rb +29 -0
- data/lib/ruby_llm/embedding.rb +29 -0
- data/lib/ruby_llm/error.rb +84 -0
- data/lib/ruby_llm/image.rb +49 -0
- data/lib/ruby_llm/message.rb +86 -0
- data/lib/ruby_llm/mime_type.rb +71 -0
- data/lib/ruby_llm/model/info.rb +111 -0
- data/lib/ruby_llm/model/modalities.rb +22 -0
- data/lib/ruby_llm/model/pricing.rb +48 -0
- data/lib/ruby_llm/model/pricing_category.rb +46 -0
- data/lib/ruby_llm/model/pricing_tier.rb +33 -0
- data/lib/ruby_llm/model.rb +7 -0
- data/lib/ruby_llm/models.json +33198 -0
- data/lib/ruby_llm/models.rb +231 -0
- data/lib/ruby_llm/models_schema.json +168 -0
- data/lib/ruby_llm/moderation.rb +56 -0
- data/lib/ruby_llm/provider.rb +243 -0
- data/lib/ruby_llm/providers/anthropic/capabilities.rb +134 -0
- data/lib/ruby_llm/providers/anthropic/chat.rb +125 -0
- data/lib/ruby_llm/providers/anthropic/content.rb +44 -0
- data/lib/ruby_llm/providers/anthropic/embeddings.rb +20 -0
- data/lib/ruby_llm/providers/anthropic/media.rb +92 -0
- data/lib/ruby_llm/providers/anthropic/models.rb +63 -0
- data/lib/ruby_llm/providers/anthropic/streaming.rb +45 -0
- data/lib/ruby_llm/providers/anthropic/tools.rb +109 -0
- data/lib/ruby_llm/providers/anthropic.rb +36 -0
- data/lib/ruby_llm/providers/bedrock/capabilities.rb +167 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +63 -0
- data/lib/ruby_llm/providers/bedrock/media.rb +61 -0
- data/lib/ruby_llm/providers/bedrock/models.rb +98 -0
- data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
- data/lib/ruby_llm/providers/bedrock/streaming/base.rb +51 -0
- data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +71 -0
- data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +67 -0
- data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +80 -0
- data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +78 -0
- data/lib/ruby_llm/providers/bedrock/streaming.rb +18 -0
- data/lib/ruby_llm/providers/bedrock.rb +82 -0
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +130 -0
- data/lib/ruby_llm/providers/deepseek/chat.rb +16 -0
- data/lib/ruby_llm/providers/deepseek.rb +30 -0
- data/lib/ruby_llm/providers/gemini/capabilities.rb +281 -0
- data/lib/ruby_llm/providers/gemini/chat.rb +454 -0
- data/lib/ruby_llm/providers/gemini/embeddings.rb +37 -0
- data/lib/ruby_llm/providers/gemini/images.rb +47 -0
- data/lib/ruby_llm/providers/gemini/media.rb +112 -0
- data/lib/ruby_llm/providers/gemini/models.rb +40 -0
- data/lib/ruby_llm/providers/gemini/streaming.rb +61 -0
- data/lib/ruby_llm/providers/gemini/tools.rb +198 -0
- data/lib/ruby_llm/providers/gemini/transcription.rb +116 -0
- data/lib/ruby_llm/providers/gemini.rb +37 -0
- data/lib/ruby_llm/providers/gpustack/chat.rb +27 -0
- data/lib/ruby_llm/providers/gpustack/media.rb +46 -0
- data/lib/ruby_llm/providers/gpustack/models.rb +90 -0
- data/lib/ruby_llm/providers/gpustack.rb +34 -0
- data/lib/ruby_llm/providers/mistral/capabilities.rb +155 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +24 -0
- data/lib/ruby_llm/providers/mistral/embeddings.rb +33 -0
- data/lib/ruby_llm/providers/mistral/models.rb +48 -0
- data/lib/ruby_llm/providers/mistral.rb +32 -0
- data/lib/ruby_llm/providers/ollama/chat.rb +27 -0
- data/lib/ruby_llm/providers/ollama/media.rb +46 -0
- data/lib/ruby_llm/providers/ollama/models.rb +36 -0
- data/lib/ruby_llm/providers/ollama.rb +30 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +299 -0
- data/lib/ruby_llm/providers/openai/chat.rb +88 -0
- data/lib/ruby_llm/providers/openai/embeddings.rb +33 -0
- data/lib/ruby_llm/providers/openai/images.rb +38 -0
- data/lib/ruby_llm/providers/openai/media.rb +81 -0
- data/lib/ruby_llm/providers/openai/models.rb +39 -0
- data/lib/ruby_llm/providers/openai/moderation.rb +34 -0
- data/lib/ruby_llm/providers/openai/streaming.rb +46 -0
- data/lib/ruby_llm/providers/openai/tools.rb +98 -0
- data/lib/ruby_llm/providers/openai/transcription.rb +70 -0
- data/lib/ruby_llm/providers/openai.rb +44 -0
- data/lib/ruby_llm/providers/openai_responses.rb +395 -0
- data/lib/ruby_llm/providers/openrouter/models.rb +73 -0
- data/lib/ruby_llm/providers/openrouter.rb +26 -0
- data/lib/ruby_llm/providers/perplexity/capabilities.rb +137 -0
- data/lib/ruby_llm/providers/perplexity/chat.rb +16 -0
- data/lib/ruby_llm/providers/perplexity/models.rb +42 -0
- data/lib/ruby_llm/providers/perplexity.rb +48 -0
- data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
- data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
- data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
- data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
- data/lib/ruby_llm/providers/vertexai/transcription.rb +16 -0
- data/lib/ruby_llm/providers/vertexai.rb +55 -0
- data/lib/ruby_llm/railtie.rb +35 -0
- data/lib/ruby_llm/responses_session.rb +77 -0
- data/lib/ruby_llm/stream_accumulator.rb +101 -0
- data/lib/ruby_llm/streaming.rb +153 -0
- data/lib/ruby_llm/tool.rb +209 -0
- data/lib/ruby_llm/tool_call.rb +22 -0
- data/lib/ruby_llm/tool_executors.rb +125 -0
- data/lib/ruby_llm/transcription.rb +35 -0
- data/lib/ruby_llm/utils.rb +91 -0
- data/lib/ruby_llm/version.rb +5 -0
- data/lib/ruby_llm.rb +140 -0
- data/lib/tasks/models.rake +525 -0
- data/lib/tasks/release.rake +67 -0
- data/lib/tasks/ruby_llm.rake +15 -0
- data/lib/tasks/vcr.rake +92 -0
- 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
|