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,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Registry of available AI models and their capabilities.
5
+ class Models
6
+ include Enumerable
7
+
8
+ class << self
9
+ def instance
10
+ @instance ||= new
11
+ end
12
+
13
+ def schema_file
14
+ File.expand_path('models_schema.json', __dir__)
15
+ end
16
+
17
+ def load_models(file = RubyLLM.config.model_registry_file)
18
+ read_from_json(file)
19
+ end
20
+
21
+ def read_from_json(file = RubyLLM.config.model_registry_file)
22
+ data = File.exist?(file) ? File.read(file) : '[]'
23
+ JSON.parse(data, symbolize_names: true).map { |model| Model::Info.new(model) }
24
+ rescue JSON::ParserError
25
+ []
26
+ end
27
+
28
+ def refresh!(remote_only: false)
29
+ provider_models = fetch_from_providers(remote_only: remote_only)
30
+ parsera_models = fetch_from_parsera
31
+ merged_models = merge_models(provider_models, parsera_models)
32
+ @instance = new(merged_models)
33
+ end
34
+
35
+ def fetch_from_providers(remote_only: true)
36
+ config = RubyLLM.config
37
+ configured_classes = if remote_only
38
+ Provider.configured_remote_providers(config)
39
+ else
40
+ Provider.configured_providers(config)
41
+ end
42
+ configured = configured_classes.map { |klass| klass.new(config) }
43
+
44
+ RubyLLM.logger.info "Fetching models from providers: #{configured.map(&:name).join(', ')}"
45
+
46
+ configured.flat_map(&:list_models)
47
+ end
48
+
49
+ def resolve(model_id, provider: nil, assume_exists: false, config: nil) # rubocop:disable Metrics/PerceivedComplexity
50
+ config ||= RubyLLM.config
51
+ provider_class = provider ? Provider.providers[provider.to_sym] : nil
52
+
53
+ if provider_class
54
+ temp_instance = provider_class.new(config)
55
+ assume_exists = true if temp_instance.local?
56
+ end
57
+
58
+ if assume_exists
59
+ raise ArgumentError, 'Provider must be specified if assume_exists is true' unless provider
60
+
61
+ provider_class ||= raise(Error, "Unknown provider: #{provider.to_sym}")
62
+ provider_instance = provider_class.new(config)
63
+
64
+ model = if provider_instance.local?
65
+ begin
66
+ Models.find(model_id, provider)
67
+ rescue ModelNotFoundError
68
+ nil
69
+ end
70
+ end
71
+
72
+ model ||= Model::Info.default(model_id, provider_instance.slug)
73
+ else
74
+ model = Models.find model_id, provider
75
+ provider_class = Provider.providers[model.provider.to_sym] || raise(Error,
76
+ "Unknown provider: #{model.provider}")
77
+ provider_instance = provider_class.new(config)
78
+ end
79
+ [model, provider_instance]
80
+ end
81
+
82
+ def method_missing(method, ...)
83
+ if instance.respond_to?(method)
84
+ instance.send(method, ...)
85
+ else
86
+ super
87
+ end
88
+ end
89
+
90
+ def respond_to_missing?(method, include_private = false)
91
+ instance.respond_to?(method, include_private) || super
92
+ end
93
+
94
+ def fetch_from_parsera
95
+ RubyLLM.logger.info 'Fetching models from Parsera API...'
96
+
97
+ connection = Connection.basic do |f|
98
+ f.request :json
99
+ f.response :json, parser_options: { symbolize_names: true }
100
+ end
101
+ response = connection.get 'https://api.parsera.org/v1/llm-specs'
102
+ models = response.body.map { |data| Model::Info.new(data) }
103
+ models.reject { |model| model.provider.nil? || model.id.nil? }
104
+ end
105
+
106
+ def merge_models(provider_models, parsera_models)
107
+ parsera_by_key = index_by_key(parsera_models)
108
+ provider_by_key = index_by_key(provider_models)
109
+
110
+ all_keys = parsera_by_key.keys | provider_by_key.keys
111
+
112
+ models = all_keys.map do |key|
113
+ parsera_model = find_parsera_model(key, parsera_by_key)
114
+ provider_model = provider_by_key[key]
115
+
116
+ if parsera_model && provider_model
117
+ add_provider_metadata(parsera_model, provider_model)
118
+ elsif parsera_model
119
+ parsera_model
120
+ else
121
+ provider_model
122
+ end
123
+ end
124
+
125
+ models.sort_by { |m| [m.provider, m.id] }
126
+ end
127
+
128
+ def find_parsera_model(key, parsera_by_key)
129
+ # Direct match
130
+ return parsera_by_key[key] if parsera_by_key[key]
131
+
132
+ # VertexAI uses same models as Gemini
133
+ provider, model_id = key.split(':', 2)
134
+ return unless provider == 'vertexai'
135
+
136
+ gemini_model = parsera_by_key["gemini:#{model_id}"]
137
+ return unless gemini_model
138
+
139
+ # Return Gemini's Parsera data but with VertexAI as provider
140
+ Model::Info.new(gemini_model.to_h.merge(provider: 'vertexai'))
141
+ end
142
+
143
+ def index_by_key(models)
144
+ models.each_with_object({}) do |model, hash|
145
+ hash["#{model.provider}:#{model.id}"] = model
146
+ end
147
+ end
148
+
149
+ def add_provider_metadata(parsera_model, provider_model)
150
+ data = parsera_model.to_h
151
+ data[:metadata] = provider_model.metadata.merge(data[:metadata] || {})
152
+ Model::Info.new(data)
153
+ end
154
+ end
155
+
156
+ def initialize(models = nil)
157
+ @models = models || self.class.load_models
158
+ end
159
+
160
+ def load_from_json!(file = RubyLLM.config.model_registry_file)
161
+ @models = self.class.read_from_json(file)
162
+ end
163
+
164
+ def save_to_json(file = RubyLLM.config.model_registry_file)
165
+ File.write(file, JSON.pretty_generate(all.map(&:to_h)))
166
+ end
167
+
168
+ def all
169
+ @models
170
+ end
171
+
172
+ def each(&)
173
+ all.each(&)
174
+ end
175
+
176
+ def find(model_id, provider = nil)
177
+ if provider
178
+ find_with_provider(model_id, provider)
179
+ else
180
+ find_without_provider(model_id)
181
+ end
182
+ end
183
+
184
+ def chat_models
185
+ self.class.new(all.select { |m| m.type == 'chat' })
186
+ end
187
+
188
+ def embedding_models
189
+ self.class.new(all.select { |m| m.type == 'embedding' || m.modalities.output.include?('embeddings') })
190
+ end
191
+
192
+ def audio_models
193
+ self.class.new(all.select { |m| m.type == 'audio' || m.modalities.output.include?('audio') })
194
+ end
195
+
196
+ def image_models
197
+ self.class.new(all.select { |m| m.type == 'image' || m.modalities.output.include?('image') })
198
+ end
199
+
200
+ def by_family(family)
201
+ self.class.new(all.select { |m| m.family == family.to_s })
202
+ end
203
+
204
+ def by_provider(provider)
205
+ self.class.new(all.select { |m| m.provider == provider.to_s })
206
+ end
207
+
208
+ def refresh!(remote_only: false)
209
+ self.class.refresh!(remote_only: remote_only)
210
+ end
211
+
212
+ def resolve(model_id, provider: nil, assume_exists: false, config: nil)
213
+ self.class.resolve(model_id, provider: provider, assume_exists: assume_exists, config: config)
214
+ end
215
+
216
+ private
217
+
218
+ def find_with_provider(model_id, provider)
219
+ resolved_id = Aliases.resolve(model_id, provider)
220
+ all.find { |m| m.id == model_id && m.provider == provider.to_s } ||
221
+ all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
222
+ raise(ModelNotFoundError, "Unknown model: #{model_id} for provider: #{provider}")
223
+ end
224
+
225
+ def find_without_provider(model_id)
226
+ all.find { |m| m.id == model_id } ||
227
+ all.find { |m| m.id == Aliases.resolve(model_id) } ||
228
+ raise(ModelNotFoundError, "Unknown model: #{model_id}")
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,168 @@
1
+ {
2
+ "title": "RubyLLM Models Schema",
3
+ "description": "Schema for validating the structure of models.json",
4
+ "type": "array",
5
+ "items": {
6
+ "type": "object",
7
+ "required": ["id", "name", "provider", "context_window", "max_output_tokens"],
8
+ "properties": {
9
+ "id": {
10
+ "type": "string",
11
+ "description": "Unique identifier for the model"
12
+ },
13
+ "name": {
14
+ "type": "string",
15
+ "description": "Display name of the model"
16
+ },
17
+ "provider": {
18
+ "type": "string",
19
+ "description": "Provider of the model (e.g., openai, anthropic, mistral)"
20
+ },
21
+ "family": {
22
+ "type": ["string", "null"],
23
+ "description": "Model family (e.g., gpt-4, claude-3)"
24
+ },
25
+ "created_at": {
26
+ "type": ["null", {"type": "string", "format": "date-time"}],
27
+ "description": "Creation date of the model"
28
+ },
29
+ "context_window": {
30
+ "type": ["null", {"type": "integer", "minimum": 0}],
31
+ "description": "Maximum context window size"
32
+ },
33
+ "max_output_tokens": {
34
+ "type": ["null", {"type": "integer", "minimum": 0}],
35
+ "description": "Maximum output tokens"
36
+ },
37
+ "knowledge_cutoff": {
38
+ "type": ["null", {"type": "string", "format": "date"}],
39
+ "description": "Knowledge cutoff date"
40
+ },
41
+ "modalities": {
42
+ "type": "object",
43
+ "required": ["input", "output"],
44
+ "properties": {
45
+ "input": {
46
+ "type": "array",
47
+ "items": {
48
+ "type": "string",
49
+ "enum": ["text", "image", "audio", "pdf", "video", "file"]
50
+ },
51
+ "uniqueItems": true,
52
+ "description": "Supported input modalities"
53
+ },
54
+ "output": {
55
+ "type": "array",
56
+ "items": {
57
+ "type": "string",
58
+ "enum": ["text", "image", "audio", "embeddings", "moderation"]
59
+ },
60
+ "uniqueItems": true,
61
+ "description": "Supported output modalities"
62
+ }
63
+ }
64
+ },
65
+ "capabilities": {
66
+ "type": "array",
67
+ "items": {
68
+ "type": "string",
69
+ "enum": [
70
+ "streaming", "function_calling", "structured_output", "predicted_outputs",
71
+ "distillation", "fine_tuning", "batch", "realtime", "image_generation",
72
+ "speech_generation", "transcription", "translation", "citations", "reasoning",
73
+ "caching", "moderation", "json_mode", "vision"
74
+ ]
75
+ },
76
+ "uniqueItems": true,
77
+ "description": "Model capabilities"
78
+ },
79
+ "pricing": {
80
+ "type": "object",
81
+ "properties": {
82
+ "text_tokens": {
83
+ "type": "object",
84
+ "required": ["standard"],
85
+ "properties": {
86
+ "standard": {
87
+ "type": "object",
88
+ "properties": {
89
+ "input_per_million": {"type": "number", "minimum": 0},
90
+ "cached_input_per_million": {"type": "number", "minimum": 0},
91
+ "output_per_million": {"type": "number", "minimum": 0},
92
+ "reasoning_output_per_million": {"type": "number", "minimum": 0}
93
+ }
94
+ },
95
+ "batch": {
96
+ "type": "object",
97
+ "properties": {
98
+ "input_per_million": {"type": "number", "minimum": 0},
99
+ "output_per_million": {"type": "number", "minimum": 0}
100
+ }
101
+ }
102
+ }
103
+ },
104
+ "images": {
105
+ "type": "object",
106
+ "properties": {
107
+ "standard": {
108
+ "type": "object",
109
+ "properties": {
110
+ "input": {"type": "number", "minimum": 0},
111
+ "output": {"type": "number", "minimum": 0}
112
+ }
113
+ },
114
+ "batch": {
115
+ "type": "object",
116
+ "properties": {
117
+ "input": {"type": "number", "minimum": 0},
118
+ "output": {"type": "number", "minimum": 0}
119
+ }
120
+ }
121
+ }
122
+ },
123
+ "audio_tokens": {
124
+ "type": "object",
125
+ "properties": {
126
+ "standard": {
127
+ "type": "object",
128
+ "properties": {
129
+ "input_per_million": {"type": "number", "minimum": 0},
130
+ "output_per_million": {"type": "number", "minimum": 0}
131
+ }
132
+ },
133
+ "batch": {
134
+ "type": "object",
135
+ "properties": {
136
+ "input_per_million": {"type": "number", "minimum": 0},
137
+ "output_per_million": {"type": "number", "minimum": 0}
138
+ }
139
+ }
140
+ }
141
+ },
142
+ "embeddings": {
143
+ "type": "object",
144
+ "properties": {
145
+ "standard": {
146
+ "type": "object",
147
+ "properties": {
148
+ "input_per_million": {"type": "number", "minimum": 0}
149
+ }
150
+ },
151
+ "batch": {
152
+ "type": "object",
153
+ "properties": {
154
+ "input_per_million": {"type": "number", "minimum": 0}
155
+ }
156
+ }
157
+ }
158
+ }
159
+ },
160
+ "description": "Pricing information for the model"
161
+ },
162
+ "metadata": {
163
+ "type": "object",
164
+ "description": "Additional metadata about the model"
165
+ }
166
+ }
167
+ }
168
+ }
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Identify potentially harmful content in text.
5
+ # https://platform.openai.com/docs/guides/moderation
6
+ class Moderation
7
+ attr_reader :id, :model, :results
8
+
9
+ def initialize(id:, model:, results:)
10
+ @id = id
11
+ @model = model
12
+ @results = results
13
+ end
14
+
15
+ def self.moderate(input,
16
+ model: nil,
17
+ provider: nil,
18
+ assume_model_exists: false,
19
+ context: nil)
20
+ config = context&.config || RubyLLM.config
21
+ model ||= config.default_moderation_model || 'omni-moderation-latest'
22
+ model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
23
+ config: config)
24
+ model_id = model.id
25
+
26
+ provider_instance.moderate(input, model: model_id)
27
+ end
28
+
29
+ # Convenience method to get content from moderation result
30
+ def content
31
+ results
32
+ end
33
+
34
+ # Check if any content was flagged
35
+ def flagged?
36
+ results.any? { |result| result['flagged'] }
37
+ end
38
+
39
+ # Get all flagged categories across all results
40
+ def flagged_categories
41
+ results.flat_map do |result|
42
+ result['categories']&.select { |_category, flagged| flagged }&.keys || []
43
+ end.uniq
44
+ end
45
+
46
+ # Get category scores for the first result (most common case)
47
+ def category_scores
48
+ results.first&.dig('category_scores') || {}
49
+ end
50
+
51
+ # Get categories for the first result (most common case)
52
+ def categories
53
+ results.first&.dig('categories') || {}
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Base class for LLM providers.
5
+ class Provider
6
+ include Streaming
7
+
8
+ attr_reader :config, :connection
9
+
10
+ def initialize(config)
11
+ @config = config
12
+ ensure_configured!
13
+ @connection = Connection.new(self, @config)
14
+ end
15
+
16
+ def api_base
17
+ raise NotImplementedError
18
+ end
19
+
20
+ def headers
21
+ {}
22
+ end
23
+
24
+ def slug
25
+ self.class.slug
26
+ end
27
+
28
+ def name
29
+ self.class.name
30
+ end
31
+
32
+ def capabilities
33
+ self.class.capabilities
34
+ end
35
+
36
+ def configuration_requirements
37
+ self.class.configuration_requirements
38
+ end
39
+
40
+ def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, &) # rubocop:disable Metrics/ParameterLists
41
+ normalized_temperature = maybe_normalize_temperature(temperature, model)
42
+
43
+ payload = Utils.deep_merge(
44
+ render_payload(
45
+ messages,
46
+ tools: tools,
47
+ temperature: normalized_temperature,
48
+ model: model,
49
+ stream: block_given?,
50
+ schema: schema
51
+ ),
52
+ params
53
+ )
54
+
55
+ if block_given?
56
+ stream_response @connection, payload, headers, &
57
+ else
58
+ sync_response @connection, payload, headers
59
+ end
60
+ end
61
+
62
+ def list_models
63
+ response = @connection.get models_url
64
+ parse_list_models_response response, slug, capabilities
65
+ end
66
+
67
+ def embed(text, model:, dimensions:)
68
+ payload = render_embedding_payload(text, model:, dimensions:)
69
+ response = @connection.post(embedding_url(model:), payload)
70
+ parse_embedding_response(response, model:, text:)
71
+ end
72
+
73
+ def moderate(input, model:)
74
+ payload = render_moderation_payload(input, model:)
75
+ response = @connection.post moderation_url, payload
76
+ parse_moderation_response(response, model:)
77
+ end
78
+
79
+ def paint(prompt, model:, size:)
80
+ payload = render_image_payload(prompt, model:, size:)
81
+ response = @connection.post images_url, payload
82
+ parse_image_response(response, model:)
83
+ end
84
+
85
+ def transcribe(audio_file, model:, language:, **options)
86
+ file_part = build_audio_file_part(audio_file)
87
+ payload = render_transcription_payload(file_part, model:, language:, **options)
88
+ response = @connection.post transcription_url, payload
89
+ parse_transcription_response(response, model:)
90
+ end
91
+
92
+ def configured?
93
+ configuration_requirements.all? { |req| @config.send(req) }
94
+ end
95
+
96
+ def local?
97
+ self.class.local?
98
+ end
99
+
100
+ def remote?
101
+ self.class.remote?
102
+ end
103
+
104
+ def parse_error(response)
105
+ return if response.body.empty?
106
+
107
+ body = try_parse_json(response.body)
108
+ case body
109
+ when Hash
110
+ body.dig('error', 'message')
111
+ when Array
112
+ body.map do |part|
113
+ part.dig('error', 'message')
114
+ end.join('. ')
115
+ else
116
+ body
117
+ end
118
+ end
119
+
120
+ def format_messages(messages)
121
+ messages.map do |msg|
122
+ {
123
+ role: msg.role.to_s,
124
+ content: msg.content
125
+ }
126
+ end
127
+ end
128
+
129
+ def format_tool_calls(_tool_calls)
130
+ nil
131
+ end
132
+
133
+ def parse_tool_calls(_tool_calls)
134
+ nil
135
+ end
136
+
137
+ class << self
138
+ def name
139
+ to_s.split('::').last
140
+ end
141
+
142
+ def slug
143
+ name.downcase
144
+ end
145
+
146
+ def capabilities
147
+ raise NotImplementedError
148
+ end
149
+
150
+ def configuration_requirements
151
+ []
152
+ end
153
+
154
+ def local?
155
+ false
156
+ end
157
+
158
+ def remote?
159
+ !local?
160
+ end
161
+
162
+ def configured?(config)
163
+ configuration_requirements.all? { |req| config.send(req) }
164
+ end
165
+
166
+ def register(name, provider_class)
167
+ providers[name.to_sym] = provider_class
168
+ end
169
+
170
+ def resolve(name)
171
+ providers[name.to_sym]
172
+ end
173
+
174
+ def for(model)
175
+ model_info = Models.find(model)
176
+ resolve model_info.provider
177
+ end
178
+
179
+ def providers
180
+ @providers ||= {}
181
+ end
182
+
183
+ def local_providers
184
+ providers.select { |_slug, provider_class| provider_class.local? }
185
+ end
186
+
187
+ def remote_providers
188
+ providers.select { |_slug, provider_class| provider_class.remote? }
189
+ end
190
+
191
+ def configured_providers(config)
192
+ providers.select do |_slug, provider_class|
193
+ provider_class.configured?(config)
194
+ end.values
195
+ end
196
+
197
+ def configured_remote_providers(config)
198
+ providers.select do |_slug, provider_class|
199
+ provider_class.remote? && provider_class.configured?(config)
200
+ end.values
201
+ end
202
+ end
203
+
204
+ private
205
+
206
+ def build_audio_file_part(file_path)
207
+ expanded_path = File.expand_path(file_path)
208
+ mime_type = Marcel::MimeType.for(Pathname.new(expanded_path))
209
+
210
+ Faraday::Multipart::FilePart.new(
211
+ expanded_path,
212
+ mime_type,
213
+ File.basename(expanded_path)
214
+ )
215
+ end
216
+
217
+ def try_parse_json(maybe_json)
218
+ return maybe_json unless maybe_json.is_a?(String)
219
+
220
+ JSON.parse(maybe_json)
221
+ rescue JSON::ParserError
222
+ maybe_json
223
+ end
224
+
225
+ def ensure_configured!
226
+ missing = configuration_requirements.reject { |req| @config.send(req) }
227
+ return if missing.empty?
228
+
229
+ raise ConfigurationError, "Missing configuration for #{name}: #{missing.join(', ')}"
230
+ end
231
+
232
+ def maybe_normalize_temperature(temperature, _model)
233
+ temperature
234
+ end
235
+
236
+ def sync_response(connection, payload, additional_headers = {})
237
+ response = connection.post completion_url, payload do |req|
238
+ req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
239
+ end
240
+ parse_completion_response response
241
+ end
242
+ end
243
+ end