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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Gemini
6
+ # Streaming methods for the Gemini API implementation
7
+ module Streaming
8
+ def stream_url
9
+ "models/#{@model}:streamGenerateContent?alt=sse"
10
+ end
11
+
12
+ def build_chunk(data)
13
+ Chunk.new(
14
+ role: :assistant,
15
+ model_id: extract_model_id(data),
16
+ content: extract_content(data),
17
+ input_tokens: extract_input_tokens(data),
18
+ output_tokens: extract_output_tokens(data),
19
+ tool_calls: extract_tool_calls(data)
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ def extract_model_id(data)
26
+ data['modelVersion']
27
+ end
28
+
29
+ def extract_content(data)
30
+ return nil unless data['candidates']&.any?
31
+
32
+ candidate = data['candidates'][0]
33
+ parts = candidate.dig('content', 'parts')
34
+ return nil unless parts
35
+
36
+ text_parts = parts.select { |p| p['text'] }
37
+ text_parts.map { |p| p['text'] }.join if text_parts.any?
38
+ end
39
+
40
+ def extract_input_tokens(data)
41
+ data.dig('usageMetadata', 'promptTokenCount')
42
+ end
43
+
44
+ def extract_output_tokens(data)
45
+ candidates = data.dig('usageMetadata', 'candidatesTokenCount') || 0
46
+ thoughts = data.dig('usageMetadata', 'thoughtsTokenCount') || 0
47
+ total = candidates + thoughts
48
+ total.positive? ? total : nil
49
+ end
50
+
51
+ def parse_streaming_error(data)
52
+ error_data = JSON.parse(data)
53
+ [error_data['error']['code'], error_data['error']['message']]
54
+ rescue JSON::ParserError => e
55
+ RubyLLM.logger.debug "Failed to parse streaming error: #{e.message}"
56
+ [500, "Failed to parse error: #{data}"]
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Gemini
6
+ # Tools methods for the Gemini API implementation
7
+ module Tools
8
+ def format_tools(tools)
9
+ return [] if tools.empty?
10
+
11
+ [{
12
+ functionDeclarations: tools.values.map { |tool| function_declaration_for(tool) }
13
+ }]
14
+ end
15
+
16
+ def format_tool_call(msg)
17
+ parts = []
18
+
19
+ if msg.content && !(msg.content.respond_to?(:empty?) && msg.content.empty?)
20
+ formatted_content = Media.format_content(msg.content)
21
+ parts.concat(formatted_content.is_a?(Array) ? formatted_content : [formatted_content])
22
+ end
23
+
24
+ msg.tool_calls.each_value do |tool_call|
25
+ parts << {
26
+ functionCall: {
27
+ name: tool_call.name,
28
+ args: tool_call.arguments
29
+ }
30
+ }
31
+ end
32
+
33
+ parts
34
+ end
35
+
36
+ def format_tool_result(msg, function_name = nil)
37
+ function_name ||= msg.tool_call_id
38
+
39
+ [{
40
+ functionResponse: {
41
+ name: function_name,
42
+ response: {
43
+ name: function_name,
44
+ content: Media.format_content(msg.content)
45
+ }
46
+ }
47
+ }]
48
+ end
49
+
50
+ def extract_tool_calls(data) # rubocop:disable Metrics/PerceivedComplexity
51
+ return nil unless data
52
+
53
+ candidate = data.is_a?(Hash) ? data.dig('candidates', 0) : nil
54
+ return nil unless candidate
55
+
56
+ parts = candidate.dig('content', 'parts')
57
+ return nil unless parts.is_a?(Array)
58
+
59
+ tool_calls = parts.each_with_object({}) do |part, result|
60
+ function_data = part['functionCall']
61
+ next unless function_data
62
+
63
+ id = SecureRandom.uuid
64
+
65
+ result[id] = ToolCall.new(
66
+ id:,
67
+ name: function_data['name'],
68
+ arguments: function_data['args'] || {}
69
+ )
70
+ end
71
+
72
+ tool_calls.empty? ? nil : tool_calls
73
+ end
74
+
75
+ private
76
+
77
+ def function_declaration_for(tool)
78
+ parameters_schema = tool.params_schema ||
79
+ RubyLLM::Tool::SchemaDefinition.from_parameters(tool.parameters)&.json_schema
80
+
81
+ declaration = {
82
+ name: tool.name,
83
+ description: tool.description
84
+ }
85
+
86
+ declaration[:parameters] = convert_tool_schema_to_gemini(parameters_schema) if parameters_schema
87
+
88
+ return declaration if tool.provider_params.empty?
89
+
90
+ RubyLLM::Utils.deep_merge(declaration, tool.provider_params)
91
+ end
92
+
93
+ def convert_tool_schema_to_gemini(schema)
94
+ return nil unless schema
95
+
96
+ schema = RubyLLM::Utils.deep_stringify_keys(schema)
97
+
98
+ raise ArgumentError, 'Gemini tool parameters must be objects' unless schema['type'] == 'object'
99
+
100
+ {
101
+ type: 'OBJECT',
102
+ properties: schema.fetch('properties', {}).transform_values { |property| convert_property(property) },
103
+ required: (schema['required'] || []).map(&:to_s)
104
+ }
105
+ end
106
+
107
+ def convert_property(property_schema) # rubocop:disable Metrics/PerceivedComplexity
108
+ normalized_schema = normalize_any_of_schema(property_schema)
109
+ working_schema = normalized_schema || property_schema
110
+
111
+ type = param_type_for_gemini(working_schema['type'])
112
+
113
+ property = {
114
+ type: type
115
+ }
116
+
117
+ copy_common_attributes(property, property_schema)
118
+ copy_common_attributes(property, working_schema)
119
+
120
+ case type
121
+ when 'ARRAY'
122
+ items_schema = working_schema['items'] || property_schema['items'] || { 'type' => 'string' }
123
+ property[:items] = convert_property(items_schema)
124
+ copy_tool_attributes(property, working_schema, %w[minItems maxItems])
125
+ copy_tool_attributes(property, property_schema, %w[minItems maxItems])
126
+ when 'OBJECT'
127
+ nested_properties = working_schema.fetch('properties', {}).transform_values do |child|
128
+ convert_property(child)
129
+ end
130
+ property[:properties] = nested_properties
131
+ required = working_schema['required'] || property_schema['required']
132
+ property[:required] = required.map(&:to_s) if required
133
+ end
134
+
135
+ property
136
+ end
137
+
138
+ def copy_common_attributes(target, source)
139
+ copy_tool_attributes(target, source, %w[description enum format nullable maximum minimum multipleOf])
140
+ end
141
+
142
+ def copy_tool_attributes(target, source, attributes)
143
+ attributes.each do |attribute|
144
+ value = schema_value(source, attribute)
145
+ next if value.nil?
146
+
147
+ target[attribute.to_sym] = value
148
+ end
149
+ end
150
+
151
+ def normalize_any_of_schema(schema) # rubocop:disable Metrics/PerceivedComplexity
152
+ any_of = schema['anyOf'] || schema[:anyOf]
153
+ return nil unless any_of.is_a?(Array) && any_of.any?
154
+
155
+ null_entries, non_null_entries = any_of.partition { |entry| schema_type(entry).to_s == 'null' }
156
+
157
+ if non_null_entries.size == 1 && null_entries.any?
158
+ normalized = RubyLLM::Utils.deep_dup(non_null_entries.first)
159
+ normalized['nullable'] = true
160
+ normalized
161
+ elsif non_null_entries.any?
162
+ RubyLLM::Utils.deep_dup(non_null_entries.first)
163
+ else
164
+ { 'type' => 'string', 'nullable' => true }
165
+ end
166
+ end
167
+
168
+ def schema_type(schema)
169
+ schema['type'] || schema[:type]
170
+ end
171
+
172
+ def schema_value(source, attribute) # rubocop:disable Metrics/PerceivedComplexity
173
+ case attribute
174
+ when 'multipleOf'
175
+ source['multipleOf'] || source[:multipleOf] || source['multiple_of'] || source[:multiple_of]
176
+ when 'minItems'
177
+ source['minItems'] || source[:minItems] || source['min_items'] || source[:min_items]
178
+ when 'maxItems'
179
+ source['maxItems'] || source[:maxItems] || source['max_items'] || source[:max_items]
180
+ else
181
+ source[attribute] || source[attribute.to_sym]
182
+ end
183
+ end
184
+
185
+ def param_type_for_gemini(type)
186
+ case type.to_s.downcase
187
+ when 'integer' then 'INTEGER'
188
+ when 'number', 'float', 'double' then 'NUMBER'
189
+ when 'boolean' then 'BOOLEAN'
190
+ when 'array' then 'ARRAY'
191
+ when 'object' then 'OBJECT'
192
+ else 'STRING'
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Gemini
6
+ # Audio transcription helpers for the Gemini API implementation
7
+ module Transcription
8
+ DEFAULT_PROMPT = 'Transcribe the provided audio and respond with only the transcript text.'
9
+
10
+ def transcribe(audio_file, model:, language:, **options)
11
+ attachment = Attachment.new(audio_file)
12
+ payload = render_transcription_payload(attachment, language:, **options)
13
+ response = @connection.post(transcription_url(model), payload)
14
+ parse_transcription_response(response, model:)
15
+ end
16
+
17
+ private
18
+
19
+ def transcription_url(model)
20
+ "models/#{model}:generateContent"
21
+ end
22
+
23
+ def render_transcription_payload(attachment, language:, **options)
24
+ prompt = build_prompt(options[:prompt], language)
25
+ audio_part = format_audio_part(attachment)
26
+
27
+ raise UnsupportedAttachmentError, attachment.mime_type unless attachment.audio?
28
+
29
+ payload = {
30
+ contents: [
31
+ {
32
+ role: 'user',
33
+ parts: [
34
+ { text: prompt },
35
+ audio_part
36
+ ]
37
+ }
38
+ ]
39
+ }
40
+
41
+ generation_config = build_generation_config(options)
42
+ payload[:generationConfig] = generation_config unless generation_config.empty?
43
+ payload[:safetySettings] = options[:safety_settings] if options[:safety_settings]
44
+
45
+ payload
46
+ end
47
+
48
+ def build_generation_config(options)
49
+ config = {}
50
+ response_mime_type = options.fetch(:response_mime_type, 'text/plain')
51
+
52
+ config[:responseMimeType] = response_mime_type if response_mime_type
53
+ config[:temperature] = options[:temperature] if options.key?(:temperature)
54
+ config[:maxOutputTokens] = options[:max_output_tokens] if options[:max_output_tokens]
55
+
56
+ config
57
+ end
58
+
59
+ def build_prompt(custom_prompt, language)
60
+ prompt = DEFAULT_PROMPT
61
+ prompt += " Respond in the #{language} language." if language
62
+ prompt += " #{custom_prompt}" if custom_prompt
63
+ prompt
64
+ end
65
+
66
+ def format_audio_part(attachment)
67
+ {
68
+ inline_data: {
69
+ mime_type: attachment.mime_type,
70
+ data: attachment.encoded
71
+ }
72
+ }
73
+ end
74
+
75
+ def parse_transcription_response(response, model:)
76
+ data = response.body
77
+ text = extract_text(data)
78
+
79
+ usage = extract_usage(data)
80
+
81
+ RubyLLM::Transcription.new(
82
+ text: text,
83
+ model: model,
84
+ input_tokens: usage[:input_tokens],
85
+ output_tokens: usage[:output_tokens]
86
+ )
87
+ end
88
+
89
+ def extract_text(data)
90
+ candidate = data.is_a?(Hash) ? data.dig('candidates', 0) : nil
91
+ return unless candidate
92
+
93
+ parts = candidate.dig('content', 'parts') || []
94
+ texts = parts.filter_map { |part| part['text'] }
95
+ texts.join if texts.any?
96
+ end
97
+
98
+ def extract_usage(data)
99
+ metadata = data.is_a?(Hash) ? data['usageMetadata'] : nil
100
+ return { input_tokens: nil, output_tokens: nil } unless metadata
101
+
102
+ {
103
+ input_tokens: metadata['promptTokenCount'],
104
+ output_tokens: sum_output_tokens(metadata)
105
+ }
106
+ end
107
+
108
+ def sum_output_tokens(metadata)
109
+ candidates = metadata['candidatesTokenCount'] || 0
110
+ thoughts = metadata['thoughtsTokenCount'] || 0
111
+ candidates + thoughts
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # Native Gemini API implementation
6
+ class Gemini < Provider
7
+ include Gemini::Chat
8
+ include Gemini::Embeddings
9
+ include Gemini::Images
10
+ include Gemini::Models
11
+ include Gemini::Transcription
12
+ include Gemini::Streaming
13
+ include Gemini::Tools
14
+ include Gemini::Media
15
+
16
+ def api_base
17
+ @config.gemini_api_base || 'https://generativelanguage.googleapis.com/v1beta'
18
+ end
19
+
20
+ def headers
21
+ {
22
+ 'x-goog-api-key' => @config.gemini_api_key
23
+ }
24
+ end
25
+
26
+ class << self
27
+ def capabilities
28
+ Gemini::Capabilities
29
+ end
30
+
31
+ def configuration_requirements
32
+ %i[gemini_api_key]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class GPUStack
6
+ # Chat methods of the GPUStack API integration
7
+ module Chat
8
+ module_function
9
+
10
+ def format_messages(messages)
11
+ messages.map do |msg|
12
+ {
13
+ role: format_role(msg.role),
14
+ content: GPUStack::Media.format_content(msg.content),
15
+ tool_calls: format_tool_calls(msg.tool_calls),
16
+ tool_call_id: msg.tool_call_id
17
+ }.compact
18
+ end
19
+ end
20
+
21
+ def format_role(role)
22
+ role.to_s
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class GPUStack
6
+ # Handles formatting of media content (images, audio) for GPUStack APIs
7
+ module Media
8
+ extend OpenAI::Media
9
+
10
+ module_function
11
+
12
+ def format_content(content)
13
+ return content.value if content.is_a?(RubyLLM::Content::Raw)
14
+ return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
15
+ return content unless content.is_a?(Content)
16
+
17
+ parts = []
18
+ parts << format_text(content.text) if content.text
19
+
20
+ content.attachments.each do |attachment|
21
+ case attachment.type
22
+ when :image
23
+ parts << GPUStack::Media.format_image(attachment)
24
+ when :text
25
+ parts << format_text_file(attachment)
26
+ else
27
+ raise UnsupportedAttachmentError, attachment.mime_type
28
+ end
29
+ end
30
+
31
+ parts
32
+ end
33
+
34
+ def format_image(image)
35
+ {
36
+ type: 'image_url',
37
+ image_url: {
38
+ url: image.for_llm,
39
+ detail: 'auto'
40
+ }
41
+ }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class GPUStack
6
+ # Models methods of the GPUStack 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
+ items = response.body['items'] || []
16
+ items.map do |model|
17
+ Model::Info.new(
18
+ id: model['name'],
19
+ name: model['name'],
20
+ created_at: model['created_at'] ? Time.parse(model['created_at']) : nil,
21
+ provider: slug,
22
+ family: 'gpustack',
23
+ metadata: {
24
+ description: model['description'],
25
+ source: model['source'],
26
+ huggingface_repo_id: model['huggingface_repo_id'],
27
+ ollama_library_model_name: model['ollama_library_model_name'],
28
+ backend: model['backend'],
29
+ meta: model['meta'],
30
+ categories: model['categories']
31
+ },
32
+ context_window: model.dig('meta', 'n_ctx'),
33
+ max_output_tokens: model.dig('meta', 'n_ctx'),
34
+ capabilities: build_capabilities(model),
35
+ modalities: build_modalities(model),
36
+ pricing: {}
37
+ )
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def determine_model_type(model)
44
+ return 'embedding' if model['categories']&.include?('embedding')
45
+ return 'chat' if model['categories']&.include?('llm')
46
+
47
+ 'other'
48
+ end
49
+
50
+ def build_capabilities(model) # rubocop:disable Metrics/PerceivedComplexity
51
+ capabilities = []
52
+
53
+ # Add streaming by default for LLM models
54
+ capabilities << 'streaming' if model['categories']&.include?('llm')
55
+
56
+ # Map GPUStack metadata to standard capabilities
57
+ capabilities << 'function_calling' if model.dig('meta', 'support_tool_calls')
58
+ capabilities << 'vision' if model.dig('meta', 'support_vision')
59
+ capabilities << 'reasoning' if model.dig('meta', 'support_reasoning')
60
+
61
+ # GPUStack models generally support structured output and json mode
62
+ capabilities << 'structured_output' if model['categories']&.include?('llm')
63
+ capabilities << 'json_mode' if model['categories']&.include?('llm')
64
+
65
+ capabilities
66
+ end
67
+
68
+ def build_modalities(model)
69
+ input_modalities = []
70
+ output_modalities = []
71
+
72
+ if model['categories']&.include?('llm')
73
+ input_modalities << 'text'
74
+ input_modalities << 'image' if model.dig('meta', 'support_vision')
75
+ input_modalities << 'audio' if model.dig('meta', 'support_audio')
76
+ output_modalities << 'text'
77
+ elsif model['categories']&.include?('embedding')
78
+ input_modalities << 'text'
79
+ output_modalities << 'embeddings'
80
+ end
81
+
82
+ {
83
+ input: input_modalities,
84
+ output: output_modalities
85
+ }
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # GPUStack API integration based on Ollama.
6
+ class GPUStack < OpenAI
7
+ include GPUStack::Chat
8
+ include GPUStack::Models
9
+ include GPUStack::Media
10
+
11
+ def api_base
12
+ @config.gpustack_api_base
13
+ end
14
+
15
+ def headers
16
+ return {} unless @config.gpustack_api_key
17
+
18
+ {
19
+ 'Authorization' => "Bearer #{@config.gpustack_api_key}"
20
+ }
21
+ end
22
+
23
+ class << self
24
+ def local?
25
+ true
26
+ end
27
+
28
+ def configuration_requirements
29
+ %i[gpustack_api_base]
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end