ruby_llm 1.13.2 → 1.16.0

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 (171) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -13
  3. data/lib/generators/ruby_llm/agent/agent_generator.rb +36 -0
  4. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -0
  5. data/lib/generators/ruby_llm/agent/templates/instructions.txt.erb.tt +0 -0
  6. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +110 -41
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +14 -15
  8. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -11
  9. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +2 -2
  10. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +1 -1
  12. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
  30. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +2 -2
  31. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +2 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +19 -7
  33. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +1 -1
  34. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +5 -3
  35. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -1
  37. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +1 -1
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -7
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +5 -7
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +11 -12
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +27 -17
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -4
  49. data/lib/generators/ruby_llm/generator_helpers.rb +41 -17
  50. data/lib/generators/ruby_llm/install/install_generator.rb +21 -18
  51. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +3 -4
  52. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +1 -1
  53. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +3 -3
  54. data/lib/generators/ruby_llm/schema/schema_generator.rb +26 -0
  55. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +2 -0
  56. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +9 -0
  57. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +13 -0
  58. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +13 -0
  59. data/lib/generators/ruby_llm/tool/tool_generator.rb +96 -0
  60. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +1 -1
  61. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  62. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  63. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +5 -7
  64. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  65. data/lib/ruby_llm/active_record/acts_as.rb +6 -26
  66. data/lib/ruby_llm/active_record/acts_as_legacy.rb +125 -29
  67. data/lib/ruby_llm/active_record/chat_methods.rb +50 -26
  68. data/lib/ruby_llm/active_record/message_methods.rb +104 -4
  69. data/lib/ruby_llm/active_record/model_methods.rb +8 -10
  70. data/lib/ruby_llm/active_record/payload_helpers.rb +29 -0
  71. data/lib/ruby_llm/active_record/tool_call_methods.rb +18 -0
  72. data/lib/ruby_llm/agent.rb +15 -2
  73. data/lib/ruby_llm/aliases.json +141 -89
  74. data/lib/ruby_llm/aliases.rb +3 -0
  75. data/lib/ruby_llm/attachment.rb +44 -40
  76. data/lib/ruby_llm/chat.rb +229 -59
  77. data/lib/ruby_llm/configuration.rb +69 -75
  78. data/lib/ruby_llm/connection.rb +37 -10
  79. data/lib/ruby_llm/content.rb +15 -1
  80. data/lib/ruby_llm/cost.rb +224 -0
  81. data/lib/ruby_llm/deprecator.rb +24 -0
  82. data/lib/ruby_llm/embedding.rb +31 -1
  83. data/lib/ruby_llm/error.rb +16 -75
  84. data/lib/ruby_llm/error_middleware.rb +81 -0
  85. data/lib/ruby_llm/image.rb +39 -4
  86. data/lib/ruby_llm/instrumentation.rb +36 -0
  87. data/lib/ruby_llm/message.rb +20 -0
  88. data/lib/ruby_llm/mime_type.rb +25 -0
  89. data/lib/ruby_llm/model/info.rb +67 -14
  90. data/lib/ruby_llm/model/pricing.rb +19 -9
  91. data/lib/ruby_llm/model/pricing_category.rb +13 -2
  92. data/lib/ruby_llm/model/pricing_tier.rb +20 -9
  93. data/lib/ruby_llm/model_registry.rb +39 -0
  94. data/lib/ruby_llm/models.json +21796 -20601
  95. data/lib/ruby_llm/models.rb +107 -34
  96. data/lib/ruby_llm/models_schema.json +3 -0
  97. data/lib/ruby_llm/provider.rb +25 -4
  98. data/lib/ruby_llm/providers/anthropic/capabilities.rb +1 -133
  99. data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
  100. data/lib/ruby_llm/providers/anthropic/models.rb +4 -8
  101. data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
  102. data/lib/ruby_llm/providers/anthropic/tools.rb +32 -3
  103. data/lib/ruby_llm/providers/anthropic.rb +4 -0
  104. data/lib/ruby_llm/providers/azure/media.rb +1 -1
  105. data/lib/ruby_llm/providers/azure.rb +4 -0
  106. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
  107. data/lib/ruby_llm/providers/bedrock/chat.rb +26 -13
  108. data/lib/ruby_llm/providers/bedrock/media.rb +21 -3
  109. data/lib/ruby_llm/providers/bedrock/models.rb +1 -1
  110. data/lib/ruby_llm/providers/bedrock/streaming.rb +10 -1
  111. data/lib/ruby_llm/providers/bedrock.rb +5 -1
  112. data/lib/ruby_llm/providers/deepseek/capabilities.rb +28 -103
  113. data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
  114. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  115. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -215
  116. data/lib/ruby_llm/providers/gemini/chat.rb +10 -4
  117. data/lib/ruby_llm/providers/gemini/images.rb +2 -2
  118. data/lib/ruby_llm/providers/gemini/media.rb +16 -9
  119. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  120. data/lib/ruby_llm/providers/gemini/streaming.rb +6 -1
  121. data/lib/ruby_llm/providers/gemini/tools.rb +5 -1
  122. data/lib/ruby_llm/providers/gemini.rb +4 -0
  123. data/lib/ruby_llm/providers/gpustack/chat.rb +8 -1
  124. data/lib/ruby_llm/providers/gpustack/models.rb +2 -0
  125. data/lib/ruby_llm/providers/gpustack.rb +4 -0
  126. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -2
  127. data/lib/ruby_llm/providers/mistral/chat.rb +56 -5
  128. data/lib/ruby_llm/providers/mistral/media.rb +55 -0
  129. data/lib/ruby_llm/providers/mistral/models.rb +2 -0
  130. data/lib/ruby_llm/providers/mistral.rb +5 -1
  131. data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
  132. data/lib/ruby_llm/providers/ollama.rb +4 -0
  133. data/lib/ruby_llm/providers/openai/capabilities.rb +157 -195
  134. data/lib/ruby_llm/providers/openai/chat.rb +61 -7
  135. data/lib/ruby_llm/providers/openai/images.rb +58 -6
  136. data/lib/ruby_llm/providers/openai/media.rb +40 -16
  137. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  138. data/lib/ruby_llm/providers/openai/streaming.rb +7 -6
  139. data/lib/ruby_llm/providers/openai/tools.rb +2 -0
  140. data/lib/ruby_llm/providers/openai/transcription.rb +1 -0
  141. data/lib/ruby_llm/providers/openai.rb +10 -0
  142. data/lib/ruby_llm/providers/openrouter/chat.rb +36 -8
  143. data/lib/ruby_llm/providers/openrouter/images.rb +3 -3
  144. data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
  145. data/lib/ruby_llm/providers/openrouter/streaming.rb +5 -6
  146. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  147. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  148. data/lib/ruby_llm/providers/perplexity/chat.rb +11 -0
  149. data/lib/ruby_llm/providers/perplexity/media.rb +62 -0
  150. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  151. data/lib/ruby_llm/providers/perplexity.rb +5 -1
  152. data/lib/ruby_llm/providers/vertexai.rb +8 -0
  153. data/lib/ruby_llm/providers/xai/chat.rb +9 -0
  154. data/lib/ruby_llm/providers/xai/models.rb +15 -27
  155. data/lib/ruby_llm/providers/xai.rb +5 -1
  156. data/lib/ruby_llm/railtie.rb +11 -1
  157. data/lib/ruby_llm/stream_accumulator.rb +45 -30
  158. data/lib/ruby_llm/streaming.rb +4 -0
  159. data/lib/ruby_llm/tokens.rb +8 -0
  160. data/lib/ruby_llm/tool.rb +24 -7
  161. data/lib/ruby_llm/tool_concurrency.rb +105 -0
  162. data/lib/ruby_llm/transcription.rb +2 -1
  163. data/lib/ruby_llm/utils.rb +39 -0
  164. data/lib/ruby_llm/version.rb +1 -1
  165. data/lib/ruby_llm.rb +11 -6
  166. data/lib/tasks/models.rake +45 -16
  167. data/lib/tasks/release.rake +51 -24
  168. data/lib/tasks/ruby_llm.rake +6 -5
  169. data/lib/tasks/vcr.rake +1 -1
  170. metadata +81 -21
  171. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'date'
4
+ require 'json'
5
+
3
6
  module RubyLLM
4
7
  # Registry of available AI models and their capabilities.
5
8
  class Models
@@ -14,8 +17,12 @@ module RubyLLM
14
17
  'deepseek' => 'deepseek',
15
18
  'mistral' => 'mistral',
16
19
  'openrouter' => 'openrouter',
17
- 'perplexity' => 'perplexity'
20
+ 'perplexity' => 'perplexity',
21
+ 'xai' => 'xai'
18
22
  }.freeze
23
+ MODELS_DEV_INPUT_MODALITIES = %w[text image audio pdf video file].freeze
24
+ MODELS_DEV_OUTPUT_MODALITIES = %w[text image audio video embeddings moderation].freeze
25
+ MODELS_DEV_AUTHORITY_CAPABILITIES = %w[function_calling structured_output reasoning vision].freeze
19
26
  PROVIDER_PREFERENCE = %w[
20
27
  openai
21
28
  anthropic
@@ -31,8 +38,32 @@ module RubyLLM
31
38
  ollama
32
39
  gpustack
33
40
  ].freeze
41
+ INSTANCE_DELEGATES = (Enumerable.instance_methods(false) + %i[
42
+ all
43
+ each
44
+ find
45
+ chat_models
46
+ embedding_models
47
+ audio_models
48
+ image_models
49
+ by_family
50
+ by_provider
51
+ load_from_json!
52
+ load_from_database!
53
+ save_to_json
54
+ ]).uniq.freeze
34
55
 
35
56
  class << self
57
+ INSTANCE_DELEGATES.each do |method_name|
58
+ define_method(method_name) do |*args, **kwargs, &block|
59
+ if kwargs.empty?
60
+ instance.public_send(method_name, *args, &block)
61
+ else
62
+ instance.public_send(method_name, *args, **kwargs, &block)
63
+ end
64
+ end
65
+ end
66
+
36
67
  def instance
37
68
  @instance ||= new
38
69
  end
@@ -42,27 +73,45 @@ module RubyLLM
42
73
  end
43
74
 
44
75
  def load_models(file = RubyLLM.config.model_registry_file)
76
+ source = RubyLLM.config.model_registry_source
77
+ if source && file == RubyLLM.config.model_registry_file
78
+ models = source.read
79
+ return models if models.any?
80
+
81
+ RubyLLM.logger.debug { 'Model registry source is empty, falling back to JSON registry' }
82
+ end
83
+
45
84
  read_from_json(file)
46
85
  end
47
86
 
48
87
  def read_from_json(file = RubyLLM.config.model_registry_file)
49
88
  data = File.exist?(file) ? File.read(file) : '[]'
50
- JSON.parse(data, symbolize_names: true).map { |model| Model::Info.new(model) }
89
+ models = JSON.parse(data, symbolize_names: true).map { |model| Model::Info.new(model) }
90
+ filter_models(models)
51
91
  rescue JSON::ParserError
52
92
  []
53
93
  end
54
94
 
95
+ def read_from_database
96
+ ModelRegistry::ActiveRecordSource.new.read
97
+ end
98
+
55
99
  def refresh!(remote_only: false)
56
- existing_models = load_existing_models
100
+ # Replaces the process-wide model registry. Call save_to_json when the
101
+ # refreshed registry should also be persisted.
102
+ RubyLLM.instrument('models.refresh.ruby_llm', remote_only:) do |payload|
103
+ existing_models = load_existing_models
57
104
 
58
- provider_fetch = fetch_provider_models(remote_only: remote_only)
59
- log_provider_fetch(provider_fetch)
105
+ provider_fetch = fetch_provider_models(remote_only: remote_only)
106
+ log_provider_fetch(provider_fetch)
60
107
 
61
- models_dev_fetch = fetch_models_dev_models(existing_models)
62
- log_models_dev_fetch(models_dev_fetch)
108
+ models_dev_fetch = fetch_models_dev_models(existing_models)
109
+ log_models_dev_fetch(models_dev_fetch)
63
110
 
64
- merged_models = merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
65
- @instance = new(merged_models)
111
+ merged_models = merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
112
+ payload[:model_count] = merged_models.size
113
+ @instance = new(merged_models)
114
+ end
66
115
  end
67
116
 
68
117
  def fetch_provider_models(remote_only: true) # rubocop:disable Metrics/PerceivedComplexity
@@ -114,7 +163,7 @@ module RubyLLM
114
163
  if assume_exists
115
164
  raise ArgumentError, 'Provider must be specified if assume_exists is true' unless provider
116
165
 
117
- provider_class ||= raise(Error, "Unknown provider: #{provider.to_sym}")
166
+ provider_class ||= raise_unknown_provider(provider)
118
167
  provider_instance = provider_class.new(config)
119
168
 
120
169
  model = if provider_instance.local?
@@ -128,25 +177,12 @@ module RubyLLM
128
177
  model ||= Model::Info.default(model_id, provider_instance.slug)
129
178
  else
130
179
  model = Models.find model_id, provider
131
- provider_class = Provider.providers[model.provider.to_sym] || raise(Error,
132
- "Unknown provider: #{model.provider}")
180
+ provider_class = Provider.providers[model.provider.to_sym] || raise_unknown_provider(model.provider)
133
181
  provider_instance = provider_class.new(config)
134
182
  end
135
183
  [model, provider_instance]
136
184
  end
137
185
 
138
- def method_missing(method, ...)
139
- if instance.respond_to?(method)
140
- instance.send(method, ...)
141
- else
142
- super
143
- end
144
- end
145
-
146
- def respond_to_missing?(method, include_private = false)
147
- instance.respond_to?(method, include_private) || super
148
- end
149
-
150
186
  def fetch_models_dev_models(existing_models) # rubocop:disable Metrics/PerceivedComplexity
151
187
  RubyLLM.logger.info 'Fetching models from models.dev API...'
152
188
 
@@ -180,6 +216,11 @@ module RubyLLM
180
216
  existing_models
181
217
  end
182
218
 
219
+ def raise_unknown_provider(provider)
220
+ available = Provider.providers.keys.join(', ')
221
+ raise Error, "Unknown provider: #{provider.inspect}. Available providers: #{available}"
222
+ end
223
+
183
224
  def log_provider_fetch(provider_fetch)
184
225
  RubyLLM.logger.info "Fetching models from providers: #{provider_fetch[:configured_names].join(', ')}"
185
226
  provider_fetch[:failed].each do |failure|
@@ -232,7 +273,13 @@ module RubyLLM
232
273
  end
233
274
  end
234
275
 
235
- models.sort_by { |m| [m.provider, m.id] }
276
+ filter_models(models).sort_by { |m| [m.provider, m.id] }
277
+ end
278
+
279
+ def filter_models(models)
280
+ models.reject do |model|
281
+ model.provider.to_s == 'vertexai' && model.id.to_s.include?('/')
282
+ end
236
283
  end
237
284
 
238
285
  def find_models_dev_model(key, models_dev_by_key)
@@ -281,7 +328,8 @@ module RubyLLM
281
328
  data[:modalities] = provider_model.modalities.to_h if blank_value?(data[:modalities])
282
329
  data[:pricing] = provider_model.pricing.to_h if blank_value?(data[:pricing])
283
330
  data[:metadata] = provider_model.metadata.merge(data[:metadata] || {})
284
- data[:capabilities] = (models_dev_model.capabilities + provider_model.capabilities).uniq
331
+ provider_capabilities = provider_model.capabilities - MODELS_DEV_AUTHORITY_CAPABILITIES
332
+ data[:capabilities] = (models_dev_model.capabilities + provider_capabilities).uniq
285
333
  normalize_embedding_modalities(data)
286
334
  Model::Info.new(data)
287
335
  end
@@ -320,7 +368,7 @@ module RubyLLM
320
368
  name: model_data[:name] || model_data[:id],
321
369
  provider: provider_slug,
322
370
  family: model_data[:family],
323
- created_at: created_date ? "#{created_date} 00:00:00 UTC" : nil,
371
+ created_at: Utils.iso_date_prefix_to_utc_midnight_string(created_date),
324
372
  context_window: model_data.dig(:limit, :context),
325
373
  max_output_tokens: model_data.dig(:limit, :output),
326
374
  knowledge_cutoff: normalize_models_dev_knowledge(model_data[:knowledge]),
@@ -338,7 +386,7 @@ module RubyLLM
338
386
  capabilities = []
339
387
  capabilities << 'function_calling' if model_data[:tool_call]
340
388
  capabilities << 'structured_output' if model_data[:structured_output]
341
- capabilities << 'reasoning' if model_data[:reasoning]
389
+ capabilities << 'reasoning' if model_data[:reasoning] || model_data[:reasoning_options]
342
390
  capabilities << 'vision' if modalities[:input].intersect?(%w[image video pdf])
343
391
  capabilities.uniq
344
392
  end
@@ -349,7 +397,8 @@ module RubyLLM
349
397
  text_standard = {
350
398
  input_per_million: cost[:input],
351
399
  output_per_million: cost[:output],
352
- cached_input_per_million: cost[:cache_read],
400
+ cache_read_input_per_million: cost[:cache_read],
401
+ cache_write_input_per_million: cost[:cache_write],
353
402
  reasoning_output_per_million: cost[:reasoning]
354
403
  }.compact
355
404
 
@@ -374,6 +423,7 @@ module RubyLLM
374
423
  last_updated: model_data[:last_updated],
375
424
  status: model_data[:status],
376
425
  interleaved: model_data[:interleaved],
426
+ reasoning_options: model_data[:reasoning_options],
377
427
  cost: model_data[:cost],
378
428
  limit: model_data[:limit],
379
429
  knowledge: model_data[:knowledge]
@@ -385,8 +435,8 @@ module RubyLLM
385
435
  normalized = { input: [], output: [] }
386
436
  return normalized unless modalities
387
437
 
388
- normalized[:input] = Array(modalities[:input]).compact
389
- normalized[:output] = Array(modalities[:output]).compact
438
+ normalized[:input] = Array(modalities[:input]).compact & MODELS_DEV_INPUT_MODALITIES
439
+ normalized[:output] = Array(modalities[:output]).compact & MODELS_DEV_OUTPUT_MODALITIES
390
440
  normalized
391
441
  end
392
442
 
@@ -401,13 +451,22 @@ module RubyLLM
401
451
  end
402
452
 
403
453
  def initialize(models = nil)
404
- @models = models || self.class.load_models
454
+ @models = self.class.filter_models(models || self.class.load_models)
405
455
  end
406
456
 
457
+ # Replaces this registry instance with models loaded from JSON.
407
458
  def load_from_json!(file = RubyLLM.config.model_registry_file)
408
459
  @models = self.class.read_from_json(file)
409
460
  end
410
461
 
462
+ # Replaces this registry instance with models loaded from the configured
463
+ # ActiveRecord model class.
464
+ def load_from_database!
465
+ @models = self.class.read_from_database
466
+ end
467
+
468
+ # Persists this registry instance to JSON without changing the global
469
+ # RubyLLM.models instance.
411
470
  def save_to_json(file = RubyLLM.config.model_registry_file)
412
471
  File.write(file, JSON.pretty_generate(all.map(&:to_h)))
413
472
  end
@@ -467,7 +526,7 @@ module RubyLLM
467
526
  resolved_id = resolve_bedrock_region_id(resolved_id) if provider.to_s == 'bedrock'
468
527
  all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
469
528
  all.find { |m| m.id == model_id && m.provider == provider.to_s } ||
470
- raise(ModelNotFoundError, "Unknown model: #{model_id} for provider: #{provider}")
529
+ raise_model_not_found(model_id, provider: provider)
471
530
  end
472
531
 
473
532
  def resolve_bedrock_region_id(model_id)
@@ -492,7 +551,21 @@ module RubyLLM
492
551
  alias_matches = all.select { |m| m.id == resolved_id }
493
552
  return preferred_match(alias_matches) if alias_matches.any?
494
553
 
495
- raise(ModelNotFoundError, "Unknown model: #{model_id}")
554
+ raise_model_not_found(model_id)
555
+ end
556
+
557
+ def raise_model_not_found(model_id, provider: nil)
558
+ message = "Unknown model: #{model_id.inspect}"
559
+ message = "#{message} for provider: #{provider.inspect}" if provider
560
+
561
+ raise ModelNotFoundError, "#{message}. #{refresh_registry_guidance}"
562
+ end
563
+
564
+ def refresh_registry_guidance
565
+ rails_model = RubyLLM.config.model_registry_class
566
+ 'If the model exists at the provider, refresh the registry with `RubyLLM.models.refresh!` ' \
567
+ 'and persist it with `RubyLLM.models.save_to_json`. ' \
568
+ "Rails model registries can call `#{rails_model}.refresh!` instead."
496
569
  end
497
570
 
498
571
  def preferred_match(candidates)
@@ -87,7 +87,10 @@
87
87
  "type": "object",
88
88
  "properties": {
89
89
  "input_per_million": {"type": "number", "minimum": 0},
90
+ "cache_read_input_per_million": {"type": "number", "minimum": 0},
91
+ "cache_write_input_per_million": {"type": "number", "minimum": 0},
90
92
  "cached_input_per_million": {"type": "number", "minimum": 0},
93
+ "cache_creation_input_per_million": {"type": "number", "minimum": 0},
91
94
  "output_per_million": {"type": "number", "minimum": 0},
92
95
  "reasoning_output_per_million": {"type": "number", "minimum": 0}
93
96
  }
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'ruby_llm/error'
5
+
3
6
  module RubyLLM
4
7
  # Base class for LLM providers.
5
8
  class Provider
@@ -81,9 +84,10 @@ module RubyLLM
81
84
  parse_moderation_response(response, model:)
82
85
  end
83
86
 
84
- def paint(prompt, model:, size:)
85
- payload = render_image_payload(prompt, model:, size:)
86
- response = @connection.post images_url, payload
87
+ def paint(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Metrics/ParameterLists
88
+ validate_paint_inputs!(with:, mask:)
89
+ payload = render_image_payload(prompt, model:, size:, with:, mask:, params:)
90
+ response = @connection.post images_url(with:, mask:), payload
87
91
  parse_image_response(response, model:)
88
92
  end
89
93
 
@@ -164,6 +168,10 @@ module RubyLLM
164
168
  []
165
169
  end
166
170
 
171
+ def configuration_options
172
+ []
173
+ end
174
+
167
175
  def local?
168
176
  false
169
177
  end
@@ -182,6 +190,7 @@ module RubyLLM
182
190
 
183
191
  def register(name, provider_class)
184
192
  providers[name.to_sym] = provider_class
193
+ RubyLLM::Configuration.register_provider_options(provider_class.configuration_options)
185
194
  end
186
195
 
187
196
  def resolve(name)
@@ -220,7 +229,17 @@ module RubyLLM
220
229
 
221
230
  private
222
231
 
232
+ def validate_paint_inputs!(with:, mask:)
233
+ return if with.nil? && mask.nil?
234
+
235
+ raise UnsupportedAttachmentError, 'image reference'
236
+ end
237
+
223
238
  def build_audio_file_part(file_path)
239
+ require 'faraday/multipart'
240
+ require 'marcel'
241
+ require 'pathname'
242
+
224
243
  expanded_path = File.expand_path(file_path)
225
244
  mime_type = Marcel::MimeType.for(Pathname.new(expanded_path))
226
245
 
@@ -243,7 +262,9 @@ module RubyLLM
243
262
  missing = configuration_requirements.reject { |req| @config.send(req) }
244
263
  return if missing.empty?
245
264
 
246
- raise ConfigurationError, "Missing configuration for #{name}: #{missing.join(', ')}"
265
+ raise ConfigurationError,
266
+ "Missing configuration for #{name}: #{missing.join(', ')}. " \
267
+ 'Set these keys on RubyLLM.config before using this provider.'
247
268
  end
248
269
 
249
270
  def maybe_normalize_temperature(temperature, _model)
@@ -3,37 +3,10 @@
3
3
  module RubyLLM
4
4
  module Providers
5
5
  class Anthropic
6
- # Determines capabilities and pricing for Anthropic models
6
+ # Provider-level capability checks used outside the model registry.
7
7
  module Capabilities
8
8
  module_function
9
9
 
10
- def determine_context_window(_model_id)
11
- 200_000
12
- end
13
-
14
- def determine_max_tokens(model_id)
15
- case model_id
16
- when /claude-3-7-sonnet/, /claude-3-5/ then 8_192
17
- else 4_096
18
- end
19
- end
20
-
21
- def get_input_price(model_id)
22
- PRICES.dig(model_family(model_id), :input) || default_input_price
23
- end
24
-
25
- def get_output_price(model_id)
26
- PRICES.dig(model_family(model_id), :output) || default_output_price
27
- end
28
-
29
- def supports_vision?(model_id)
30
- !model_id.match?(/claude-[12]/)
31
- end
32
-
33
- def supports_functions?(model_id)
34
- !model_id.match?(/claude-[12]/)
35
- end
36
-
37
10
  def supports_tool_choice?(_model_id)
38
11
  true
39
12
  end
@@ -41,111 +14,6 @@ module RubyLLM
41
14
  def supports_tool_parallel_control?(_model_id)
42
15
  true
43
16
  end
44
-
45
- def supports_json_mode?(model_id)
46
- !model_id.match?(/claude-[12]/)
47
- end
48
-
49
- def supports_structured_output?(model_id)
50
- match = model_id.match(/claude-(?:sonnet|opus|haiku)-(\d+)-(\d+)/)
51
- return false unless match
52
-
53
- major = match[1].to_i
54
- minor = match[2].to_i
55
- major > 4 || (major == 4 && minor >= 5)
56
- end
57
-
58
- def supports_extended_thinking?(model_id)
59
- model_id.match?(/claude-3-7-sonnet/)
60
- end
61
-
62
- def model_family(model_id)
63
- case model_id
64
- when /claude-3-7-sonnet/ then 'claude-3-7-sonnet'
65
- when /claude-3-5-sonnet/ then 'claude-3-5-sonnet'
66
- when /claude-3-5-haiku/ then 'claude-3-5-haiku'
67
- when /claude-3-opus/ then 'claude-3-opus'
68
- when /claude-3-sonnet/ then 'claude-3-sonnet'
69
- when /claude-3-haiku/ then 'claude-3-haiku'
70
- else 'claude-2'
71
- end
72
- end
73
-
74
- def model_type(_)
75
- 'chat'
76
- end
77
-
78
- PRICES = {
79
- 'claude-3-7-sonnet': { input: 3.0, output: 15.0 },
80
- 'claude-3-5-sonnet': { input: 3.0, output: 15.0 },
81
- 'claude-3-5-haiku': { input: 0.80, output: 4.0 },
82
- 'claude-3-opus': { input: 15.0, output: 75.0 },
83
- 'claude-3-haiku': { input: 0.25, output: 1.25 },
84
- 'claude-2': { input: 3.0, output: 15.0 }
85
- }.freeze
86
-
87
- def default_input_price
88
- 3.0
89
- end
90
-
91
- def default_output_price
92
- 15.0
93
- end
94
-
95
- def modalities_for(model_id)
96
- modalities = {
97
- input: ['text'],
98
- output: ['text']
99
- }
100
-
101
- unless model_id.match?(/claude-[12]/)
102
- modalities[:input] << 'image'
103
- modalities[:input] << 'pdf'
104
- end
105
-
106
- modalities
107
- end
108
-
109
- def capabilities_for(model_id)
110
- capabilities = ['streaming']
111
-
112
- unless model_id.match?(/claude-[12]/)
113
- capabilities << 'function_calling'
114
- capabilities << 'batch'
115
- end
116
-
117
- capabilities << 'structured_output' if supports_structured_output?(model_id)
118
- capabilities << 'reasoning' if model_id.match?(/claude-3-7-sonnet|claude-(?:sonnet|opus|haiku)-4/)
119
- capabilities << 'citations' if model_id.match?(/claude-3\.5|claude-3-7/)
120
- capabilities
121
- end
122
-
123
- def pricing_for(model_id)
124
- family = model_family(model_id)
125
- prices = PRICES.fetch(family.to_sym, { input: default_input_price, output: default_output_price })
126
-
127
- standard_pricing = {
128
- input_per_million: prices[:input],
129
- output_per_million: prices[:output]
130
- }
131
-
132
- batch_pricing = {
133
- input_per_million: prices[:input] * 0.5,
134
- output_per_million: prices[:output] * 0.5
135
- }
136
-
137
- if model_id.match?(/claude-3-7/)
138
- standard_pricing[:reasoning_output_per_million] = prices[:output] * 2.5
139
- batch_pricing[:reasoning_output_per_million] = prices[:output] * 1.25
140
- end
141
-
142
- {
143
- text_tokens: {
144
- standard: standard_pricing,
145
- batch: batch_pricing
146
- }
147
- }
148
- end
149
17
  end
150
18
  end
151
19
  end
@@ -31,13 +31,9 @@ module RubyLLM
31
31
  def build_system_content(system_messages)
32
32
  return [] if system_messages.empty?
33
33
 
34
- if system_messages.length > 1
35
- RubyLLM.logger.warn(
36
- "Anthropic's Claude implementation only supports a single system message. " \
37
- 'Multiple system messages will be combined into one.'
38
- )
39
- end
40
-
34
+ # Anthropic's `system` parameter accepts an array of text content blocks
35
+ # (each optionally with cache_control); each :system message becomes its
36
+ # own block in the resulting array.
41
37
  system_messages.flat_map do |msg|
42
38
  content = msg.content
43
39
 
@@ -57,8 +53,7 @@ module RubyLLM
57
53
  max_tokens: model.max_tokens || 4096
58
54
  }
59
55
 
60
- thinking_payload = build_thinking_payload(thinking)
61
- payload[:thinking] = thinking_payload if thinking_payload
56
+ add_thinking_fields(payload, thinking, model)
62
57
 
63
58
  payload
64
59
  end
@@ -72,7 +67,7 @@ module RubyLLM
72
67
  end
73
68
  payload[:system] = system_content unless system_content.empty?
74
69
  payload[:temperature] = temperature unless temperature.nil?
75
- payload[:output_config] = build_output_config(schema) if schema
70
+ payload[:output_config] = payload.fetch(:output_config, {}).merge(build_output_config(schema)) if schema
76
71
  end
77
72
 
78
73
  def build_output_config(schema)
@@ -176,7 +171,7 @@ module RubyLLM
176
171
  end
177
172
 
178
173
  content_blocks = prepend_thinking_block([], msg, thinking_enabled)
179
- content_blocks << Media.format_text(msg.content) unless msg.content.nil? || msg.content.empty?
174
+ append_formatted_content(content_blocks, msg.content) unless msg.content.nil? || msg.content.empty?
180
175
 
181
176
  msg.tool_calls.each_value do |tool_call|
182
177
  content_blocks << {
@@ -235,18 +230,57 @@ module RubyLLM
235
230
  end
236
231
  end
237
232
 
238
- def build_thinking_payload(thinking)
233
+ def add_thinking_fields(payload, thinking, model)
234
+ thinking_payload = build_thinking_payload(thinking, model)
235
+ return unless thinking_payload
236
+
237
+ payload[:thinking] = thinking_payload[:thinking] if thinking_payload[:thinking]
238
+ return unless thinking_payload[:output_config]
239
+
240
+ payload[:output_config] = payload.fetch(:output_config, {}).merge(thinking_payload[:output_config])
241
+ end
242
+
243
+ def build_thinking_payload(thinking, model)
239
244
  return nil unless thinking&.enabled?
240
245
 
246
+ effort = resolve_effort(thinking)
247
+ return nil if effort == 'none'
248
+
241
249
  budget = resolve_budget(thinking)
242
- raise ArgumentError, 'Anthropic thinking requires a budget' if budget.nil?
250
+ if budget
251
+ return enabled_thinking_payload(budget) if model.reasoning_option('budget_tokens')
252
+
253
+ raise ArgumentError, "Anthropic thinking budget is not supported for #{model.id}"
254
+ end
255
+
256
+ raise ArgumentError, 'Anthropic adaptive thinking requires an effort' if effort.nil?
257
+ return adaptive_thinking_payload(effort) if model.reasoning_option('effort')
243
258
 
259
+ raise ArgumentError, "Anthropic thinking effort is not supported for #{model.id}"
260
+ end
261
+
262
+ def enabled_thinking_payload(budget)
244
263
  {
245
- type: 'enabled',
246
- budget_tokens: budget
264
+ thinking: {
265
+ type: 'enabled',
266
+ budget_tokens: budget
267
+ }
247
268
  }
248
269
  end
249
270
 
271
+ def adaptive_thinking_payload(effort)
272
+ {
273
+ thinking: { type: 'adaptive' },
274
+ output_config: { effort: effort }
275
+ }
276
+ end
277
+
278
+ def resolve_effort(thinking)
279
+ effort = thinking.respond_to?(:effort) ? thinking.effort : nil
280
+ effort = effort.to_s if effort
281
+ effort.nil? || effort.empty? ? nil : effort
282
+ end
283
+
250
284
  def resolve_budget(thinking)
251
285
  budget = thinking.respond_to?(:budget) ? thinking.budget : thinking
252
286
  budget.is_a?(Integer) ? budget : nil
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module RubyLLM
4
6
  module Providers
5
7
  class Anthropic
@@ -11,21 +13,15 @@ module RubyLLM
11
13
  'v1/models'
12
14
  end
13
15
 
14
- def parse_list_models_response(response, slug, capabilities)
16
+ def parse_list_models_response(response, slug, _capabilities)
15
17
  Array(response.body['data']).map do |model_data|
16
18
  model_id = model_data['id']
17
19
 
18
20
  Model::Info.new(
19
21
  id: model_id,
20
- name: model_data['display_name'],
22
+ name: model_data['display_name'] || model_id,
21
23
  provider: slug,
22
- family: capabilities.model_family(model_id),
23
24
  created_at: Time.parse(model_data['created_at']),
24
- context_window: capabilities.determine_context_window(model_id),
25
- max_output_tokens: capabilities.determine_max_tokens(model_id),
26
- modalities: capabilities.modalities_for(model_id),
27
- capabilities: capabilities.capabilities_for(model_id),
28
- pricing: capabilities.pricing_for(model_id),
29
25
  metadata: {}
30
26
  )
31
27
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module RubyLLM
4
6
  module Providers
5
7
  class Anthropic