dify_llm 1.9.1 → 1.14.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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -8
  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 +2 -2
  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 +37 -17
  50. data/lib/generators/ruby_llm/install/install_generator.rb +22 -18
  51. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +1 -1
  52. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +4 -1
  53. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +4 -10
  54. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +2 -1
  55. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -2
  56. data/lib/generators/ruby_llm/schema/schema_generator.rb +26 -0
  57. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +2 -0
  58. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +9 -0
  59. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +13 -0
  60. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +13 -0
  61. data/lib/generators/ruby_llm/tool/tool_generator.rb +96 -0
  62. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +19 -0
  63. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +50 -0
  64. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  65. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  66. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +1 -1
  67. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +2 -4
  68. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  69. data/lib/ruby_llm/active_record/acts_as.rb +10 -4
  70. data/lib/ruby_llm/active_record/acts_as_legacy.rb +132 -27
  71. data/lib/ruby_llm/active_record/chat_methods.rb +132 -28
  72. data/lib/ruby_llm/active_record/message_methods.rb +58 -8
  73. data/lib/ruby_llm/active_record/model_methods.rb +1 -1
  74. data/lib/ruby_llm/active_record/payload_helpers.rb +26 -0
  75. data/lib/ruby_llm/active_record/tool_call_methods.rb +15 -0
  76. data/lib/ruby_llm/agent.rb +365 -0
  77. data/lib/ruby_llm/aliases.json +199 -62
  78. data/lib/ruby_llm/attachment.rb +15 -4
  79. data/lib/ruby_llm/chat.rb +150 -22
  80. data/lib/ruby_llm/configuration.rb +65 -65
  81. data/lib/ruby_llm/connection.rb +11 -7
  82. data/lib/ruby_llm/content.rb +6 -2
  83. data/lib/ruby_llm/error.rb +37 -1
  84. data/lib/ruby_llm/message.rb +43 -15
  85. data/lib/ruby_llm/model/info.rb +15 -13
  86. data/lib/ruby_llm/models.json +37560 -14094
  87. data/lib/ruby_llm/models.rb +321 -38
  88. data/lib/ruby_llm/models_schema.json +2 -2
  89. data/lib/ruby_llm/provider.rb +26 -4
  90. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -119
  91. data/lib/ruby_llm/providers/anthropic/chat.rb +149 -17
  92. data/lib/ruby_llm/providers/anthropic/media.rb +2 -2
  93. data/lib/ruby_llm/providers/anthropic/models.rb +3 -9
  94. data/lib/ruby_llm/providers/anthropic/streaming.rb +25 -1
  95. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
  96. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  97. data/lib/ruby_llm/providers/azure/chat.rb +29 -0
  98. data/lib/ruby_llm/providers/azure/embeddings.rb +24 -0
  99. data/lib/ruby_llm/providers/azure/media.rb +45 -0
  100. data/lib/ruby_llm/providers/azure/models.rb +14 -0
  101. data/lib/ruby_llm/providers/azure.rb +148 -0
  102. data/lib/ruby_llm/providers/bedrock/auth.rb +122 -0
  103. data/lib/ruby_llm/providers/bedrock/chat.rb +357 -28
  104. data/lib/ruby_llm/providers/bedrock/media.rb +62 -33
  105. data/lib/ruby_llm/providers/bedrock/models.rb +107 -62
  106. data/lib/ruby_llm/providers/bedrock/streaming.rb +309 -8
  107. data/lib/ruby_llm/providers/bedrock.rb +69 -52
  108. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -114
  109. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  110. data/lib/ruby_llm/providers/dify/chat.rb +82 -7
  111. data/lib/ruby_llm/providers/dify/media.rb +2 -2
  112. data/lib/ruby_llm/providers/dify/streaming.rb +26 -4
  113. data/lib/ruby_llm/providers/dify.rb +4 -0
  114. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -207
  115. data/lib/ruby_llm/providers/gemini/chat.rb +88 -6
  116. data/lib/ruby_llm/providers/gemini/images.rb +1 -1
  117. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  118. data/lib/ruby_llm/providers/gemini/streaming.rb +34 -2
  119. data/lib/ruby_llm/providers/gemini/tools.rb +35 -3
  120. data/lib/ruby_llm/providers/gemini.rb +4 -0
  121. data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
  122. data/lib/ruby_llm/providers/gpustack/chat.rb +1 -1
  123. data/lib/ruby_llm/providers/gpustack.rb +8 -0
  124. data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
  125. data/lib/ruby_llm/providers/mistral/chat.rb +59 -1
  126. data/lib/ruby_llm/providers/mistral.rb +4 -0
  127. data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
  128. data/lib/ruby_llm/providers/ollama/chat.rb +1 -1
  129. data/lib/ruby_llm/providers/ollama.rb +11 -1
  130. data/lib/ruby_llm/providers/openai/capabilities.rb +96 -192
  131. data/lib/ruby_llm/providers/openai/chat.rb +101 -7
  132. data/lib/ruby_llm/providers/openai/media.rb +5 -2
  133. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  134. data/lib/ruby_llm/providers/openai/streaming.rb +11 -3
  135. data/lib/ruby_llm/providers/openai/temperature.rb +28 -0
  136. data/lib/ruby_llm/providers/openai/tools.rb +27 -2
  137. data/lib/ruby_llm/providers/openai.rb +11 -1
  138. data/lib/ruby_llm/providers/openrouter/chat.rb +168 -0
  139. data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
  140. data/lib/ruby_llm/providers/openrouter/streaming.rb +74 -0
  141. data/lib/ruby_llm/providers/openrouter.rb +37 -1
  142. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  143. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  144. data/lib/ruby_llm/providers/perplexity.rb +4 -0
  145. data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
  146. data/lib/ruby_llm/providers/vertexai.rb +23 -7
  147. data/lib/ruby_llm/providers/xai/chat.rb +15 -0
  148. data/lib/ruby_llm/providers/xai/models.rb +75 -0
  149. data/lib/ruby_llm/providers/xai.rb +32 -0
  150. data/lib/ruby_llm/stream_accumulator.rb +120 -18
  151. data/lib/ruby_llm/streaming.rb +82 -60
  152. data/lib/ruby_llm/thinking.rb +49 -0
  153. data/lib/ruby_llm/tokens.rb +47 -0
  154. data/lib/ruby_llm/tool.rb +49 -4
  155. data/lib/ruby_llm/tool_call.rb +6 -3
  156. data/lib/ruby_llm/version.rb +1 -1
  157. data/lib/ruby_llm.rb +14 -8
  158. data/lib/tasks/models.rake +62 -23
  159. data/lib/tasks/release.rake +1 -1
  160. data/lib/tasks/ruby_llm.rake +9 -1
  161. data/lib/tasks/vcr.rake +33 -1
  162. metadata +67 -16
  163. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
  164. data/lib/ruby_llm/providers/bedrock/capabilities.rb +0 -167
  165. data/lib/ruby_llm/providers/bedrock/signing.rb +0 -831
  166. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -51
  167. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -71
  168. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -67
  169. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -80
  170. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +0 -78
@@ -5,6 +5,33 @@ module RubyLLM
5
5
  class Models
6
6
  include Enumerable
7
7
 
8
+ MODELS_DEV_PROVIDER_MAP = {
9
+ 'openai' => 'openai',
10
+ 'anthropic' => 'anthropic',
11
+ 'google' => 'gemini',
12
+ 'google-vertex' => 'vertexai',
13
+ 'amazon-bedrock' => 'bedrock',
14
+ 'deepseek' => 'deepseek',
15
+ 'mistral' => 'mistral',
16
+ 'openrouter' => 'openrouter',
17
+ 'perplexity' => 'perplexity'
18
+ }.freeze
19
+ PROVIDER_PREFERENCE = %w[
20
+ openai
21
+ anthropic
22
+ gemini
23
+ vertexai
24
+ bedrock
25
+ openrouter
26
+ deepseek
27
+ mistral
28
+ perplexity
29
+ xai
30
+ azure
31
+ ollama
32
+ gpustack
33
+ ].freeze
34
+
8
35
  class << self
9
36
  def instance
10
37
  @instance ||= new
@@ -20,30 +47,60 @@ module RubyLLM
20
47
 
21
48
  def read_from_json(file = RubyLLM.config.model_registry_file)
22
49
  data = File.exist?(file) ? File.read(file) : '[]'
23
- JSON.parse(data, symbolize_names: true).map { |model| Model::Info.new(model) }
50
+ models = JSON.parse(data, symbolize_names: true).map { |model| Model::Info.new(model) }
51
+ filter_models(models)
24
52
  rescue JSON::ParserError
25
53
  []
26
54
  end
27
55
 
28
56
  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)
57
+ existing_models = load_existing_models
58
+
59
+ provider_fetch = fetch_provider_models(remote_only: remote_only)
60
+ log_provider_fetch(provider_fetch)
61
+
62
+ models_dev_fetch = fetch_models_dev_models(existing_models)
63
+ log_models_dev_fetch(models_dev_fetch)
64
+
65
+ merged_models = merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
32
66
  @instance = new(merged_models)
33
67
  end
34
68
 
35
- def fetch_from_providers(remote_only: true)
69
+ def fetch_provider_models(remote_only: true) # rubocop:disable Metrics/PerceivedComplexity
36
70
  config = RubyLLM.config
71
+ provider_classes = remote_only ? Provider.remote_providers.values : Provider.providers.values
37
72
  configured_classes = if remote_only
38
73
  Provider.configured_remote_providers(config)
39
74
  else
40
75
  Provider.configured_providers(config)
41
76
  end
42
- configured = configured_classes.map { |klass| klass.new(config) }
77
+ configured = configured_classes.select { |klass| provider_classes.include?(klass) }
78
+ result = {
79
+ models: [],
80
+ fetched_providers: [],
81
+ configured_names: configured.map(&:name),
82
+ failed: []
83
+ }
84
+
85
+ provider_classes.each do |provider_class|
86
+ next if remote_only && provider_class.local?
87
+ next unless provider_class.configured?(config)
88
+
89
+ begin
90
+ result[:models].concat(provider_class.new(config).list_models)
91
+ result[:fetched_providers] << provider_class.slug
92
+ rescue StandardError => e
93
+ result[:failed] << { name: provider_class.name, slug: provider_class.slug, error: e }
94
+ end
95
+ end
43
96
 
44
- RubyLLM.logger.info "Fetching models from providers: #{configured.map(&:name).join(', ')}"
97
+ result[:fetched_providers].uniq!
98
+ result
99
+ end
45
100
 
46
- configured.flat_map(&:list_models)
101
+ # Backwards-compatible wrapper used by specs.
102
+ def fetch_from_providers(remote_only: true)
103
+ fetch_provider_models(remote_only: remote_only)[:models]
47
104
  end
48
105
 
49
106
  def resolve(model_id, provider: nil, assume_exists: false, config: nil) # rubocop:disable Metrics/PerceivedComplexity
@@ -52,7 +109,7 @@ module RubyLLM
52
109
 
53
110
  if provider_class
54
111
  temp_instance = provider_class.new(config)
55
- assume_exists = true if temp_instance.local?
112
+ assume_exists = true if temp_instance.local? || temp_instance.assume_models_exist?
56
113
  end
57
114
 
58
115
  if assume_exists
@@ -91,70 +148,267 @@ module RubyLLM
91
148
  instance.respond_to?(method, include_private) || super
92
149
  end
93
150
 
94
- def fetch_from_parsera
95
- RubyLLM.logger.info 'Fetching models from Parsera API...'
151
+ def fetch_models_dev_models(existing_models) # rubocop:disable Metrics/PerceivedComplexity
152
+ RubyLLM.logger.info 'Fetching models from models.dev API...'
96
153
 
97
154
  connection = Connection.basic do |f|
98
155
  f.request :json
99
156
  f.response :json, parser_options: { symbolize_names: true }
100
157
  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? }
158
+ response = connection.get 'https://models.dev/api.json'
159
+ providers = response.body || {}
160
+
161
+ models = providers.flat_map do |provider_key, provider_data|
162
+ provider_slug = MODELS_DEV_PROVIDER_MAP[provider_key.to_s]
163
+ next [] unless provider_slug
164
+
165
+ (provider_data[:models] || {}).values.map do |model_data|
166
+ Model::Info.new(models_dev_model_to_info(model_data, provider_slug, provider_key.to_s))
167
+ end
168
+ end
169
+ { models: models.reject { |model| model.provider.nil? || model.id.nil? }, fetched: true }
170
+ rescue StandardError => e
171
+ RubyLLM.logger.warn("Failed to fetch models.dev (#{e.class}: #{e.message}). Keeping existing.")
172
+ {
173
+ models: existing_models.select { |model| model.metadata[:source] == 'models.dev' },
174
+ fetched: false
175
+ }
104
176
  end
105
177
 
106
- def merge_models(provider_models, parsera_models)
107
- parsera_by_key = index_by_key(parsera_models)
178
+ def load_existing_models
179
+ existing_models = instance&.all
180
+ existing_models = read_from_json if existing_models.nil? || existing_models.empty?
181
+ existing_models
182
+ end
183
+
184
+ def log_provider_fetch(provider_fetch)
185
+ RubyLLM.logger.info "Fetching models from providers: #{provider_fetch[:configured_names].join(', ')}"
186
+ provider_fetch[:failed].each do |failure|
187
+ RubyLLM.logger.warn(
188
+ "Failed to fetch #{failure[:name]} models (#{failure[:error].class}: #{failure[:error].message}). " \
189
+ 'Keeping existing.'
190
+ )
191
+ end
192
+ end
193
+
194
+ def log_models_dev_fetch(models_dev_fetch)
195
+ return if models_dev_fetch[:fetched]
196
+
197
+ RubyLLM.logger.warn('Using cached models.dev data due to fetch failure.')
198
+ end
199
+
200
+ def merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
201
+ existing_by_provider = existing_models.group_by(&:provider)
202
+ preserved_models = existing_by_provider
203
+ .except(*provider_fetch[:fetched_providers])
204
+ .values
205
+ .flatten
206
+
207
+ provider_models = provider_fetch[:models] + preserved_models
208
+ models_dev_models = if models_dev_fetch[:fetched]
209
+ models_dev_fetch[:models]
210
+ else
211
+ existing_models.select { |model| model.metadata[:source] == 'models.dev' }
212
+ end
213
+
214
+ merge_models(provider_models, models_dev_models)
215
+ end
216
+
217
+ def merge_models(provider_models, models_dev_models)
218
+ models_dev_by_key = index_by_key(models_dev_models)
108
219
  provider_by_key = index_by_key(provider_models)
109
220
 
110
- all_keys = parsera_by_key.keys | provider_by_key.keys
221
+ all_keys = models_dev_by_key.keys | provider_by_key.keys
111
222
 
112
223
  models = all_keys.map do |key|
113
- parsera_model = find_parsera_model(key, parsera_by_key)
224
+ models_dev_model = find_models_dev_model(key, models_dev_by_key)
114
225
  provider_model = provider_by_key[key]
115
226
 
116
- if parsera_model && provider_model
117
- add_provider_metadata(parsera_model, provider_model)
118
- elsif parsera_model
119
- parsera_model
227
+ if models_dev_model && provider_model
228
+ add_provider_metadata(models_dev_model, provider_model)
229
+ elsif models_dev_model
230
+ models_dev_model
120
231
  else
121
232
  provider_model
122
233
  end
123
234
  end
124
235
 
125
- models.sort_by { |m| [m.provider, m.id] }
236
+ filter_models(models).sort_by { |m| [m.provider, m.id] }
237
+ end
238
+
239
+ def filter_models(models)
240
+ models.reject do |model|
241
+ model.provider.to_s == 'vertexai' && model.id.to_s.include?('/')
242
+ end
126
243
  end
127
244
 
128
- def find_parsera_model(key, parsera_by_key)
245
+ def find_models_dev_model(key, models_dev_by_key)
129
246
  # Direct match
130
- return parsera_by_key[key] if parsera_by_key[key]
247
+ return models_dev_by_key[key] if models_dev_by_key[key]
131
248
 
132
- # VertexAI uses same models as Gemini
133
249
  provider, model_id = key.split(':', 2)
250
+ if provider == 'bedrock'
251
+ normalized_id = model_id.sub(/^[a-z]{2}\./, '')
252
+ context_override = nil
253
+ normalized_id = normalized_id.gsub(/:(\d+)k\b/) do
254
+ context_override = Regexp.last_match(1).to_i * 1000
255
+ ''
256
+ end
257
+ bedrock_model = models_dev_by_key["bedrock:#{normalized_id}"]
258
+ if bedrock_model
259
+ data = bedrock_model.to_h.merge(id: model_id)
260
+ data[:context_window] = context_override if context_override
261
+ return Model::Info.new(data)
262
+ end
263
+ end
264
+
265
+ # VertexAI uses same models as Gemini
134
266
  return unless provider == 'vertexai'
135
267
 
136
- gemini_model = parsera_by_key["gemini:#{model_id}"]
268
+ gemini_model = models_dev_by_key["gemini:#{model_id}"]
137
269
  return unless gemini_model
138
270
 
139
- # Return Gemini's Parsera data but with VertexAI as provider
271
+ # Return Gemini's models.dev data but with VertexAI as provider
140
272
  Model::Info.new(gemini_model.to_h.merge(provider: 'vertexai'))
141
273
  end
142
274
 
143
275
  def index_by_key(models)
144
- models.each_with_object({}) do |model, hash|
145
- hash["#{model.provider}:#{model.id}"] = model
276
+ models.to_h do |model|
277
+ ["#{model.provider}:#{model.id}", model]
146
278
  end
147
279
  end
148
280
 
149
- def add_provider_metadata(parsera_model, provider_model)
150
- data = parsera_model.to_h
281
+ def add_provider_metadata(models_dev_model, provider_model) # rubocop:disable Metrics/PerceivedComplexity
282
+ data = models_dev_model.to_h
283
+ data[:name] = provider_model.name if blank_value?(data[:name])
284
+ data[:family] = provider_model.family if blank_value?(data[:family])
285
+ data[:created_at] = provider_model.created_at if blank_value?(data[:created_at])
286
+ data[:context_window] = provider_model.context_window if blank_value?(data[:context_window])
287
+ data[:max_output_tokens] = provider_model.max_output_tokens if blank_value?(data[:max_output_tokens])
288
+ data[:modalities] = provider_model.modalities.to_h if blank_value?(data[:modalities])
289
+ data[:pricing] = provider_model.pricing.to_h if blank_value?(data[:pricing])
151
290
  data[:metadata] = provider_model.metadata.merge(data[:metadata] || {})
291
+ data[:capabilities] = (models_dev_model.capabilities + provider_model.capabilities).uniq
292
+ normalize_embedding_modalities(data)
152
293
  Model::Info.new(data)
153
294
  end
295
+
296
+ def normalize_embedding_modalities(data)
297
+ return unless data[:id].to_s.include?('embedding')
298
+
299
+ modalities = data[:modalities].to_h
300
+ modalities[:input] = ['text'] if modalities[:input].nil? || modalities[:input].empty?
301
+ modalities[:output] = ['embeddings']
302
+ data[:modalities] = modalities
303
+ end
304
+
305
+ def blank_value?(value)
306
+ return true if value.nil?
307
+ return value.empty? if value.is_a?(String) || value.is_a?(Array)
308
+
309
+ if value.is_a?(Hash)
310
+ return true if value.empty?
311
+
312
+ return value.values.all? { |nested| blank_value?(nested) }
313
+ end
314
+
315
+ false
316
+ end
317
+
318
+ def models_dev_model_to_info(model_data, provider_slug, provider_key)
319
+ modalities = normalize_models_dev_modalities(model_data[:modalities])
320
+ capabilities = models_dev_capabilities(model_data, modalities)
321
+
322
+ created_date = [model_data[:release_date], model_data[:last_updated]]
323
+ .find { |value| !value.to_s.strip.empty? }
324
+
325
+ data = {
326
+ id: model_data[:id],
327
+ name: model_data[:name] || model_data[:id],
328
+ provider: provider_slug,
329
+ family: model_data[:family],
330
+ created_at: created_date ? "#{created_date} 00:00:00 UTC" : nil,
331
+ context_window: model_data.dig(:limit, :context),
332
+ max_output_tokens: model_data.dig(:limit, :output),
333
+ knowledge_cutoff: normalize_models_dev_knowledge(model_data[:knowledge]),
334
+ modalities: modalities,
335
+ capabilities: capabilities,
336
+ pricing: models_dev_pricing(model_data[:cost]),
337
+ metadata: models_dev_metadata(model_data, provider_key)
338
+ }
339
+
340
+ normalize_embedding_modalities(data)
341
+ data
342
+ end
343
+
344
+ def models_dev_capabilities(model_data, modalities)
345
+ capabilities = []
346
+ capabilities << 'function_calling' if model_data[:tool_call]
347
+ capabilities << 'structured_output' if model_data[:structured_output]
348
+ capabilities << 'reasoning' if model_data[:reasoning]
349
+ capabilities << 'vision' if modalities[:input].intersect?(%w[image video pdf])
350
+ capabilities.uniq
351
+ end
352
+
353
+ def models_dev_pricing(cost)
354
+ return {} unless cost
355
+
356
+ text_standard = {
357
+ input_per_million: cost[:input],
358
+ output_per_million: cost[:output],
359
+ cached_input_per_million: cost[:cache_read],
360
+ reasoning_output_per_million: cost[:reasoning]
361
+ }.compact
362
+
363
+ audio_standard = {
364
+ input_per_million: cost[:input_audio],
365
+ output_per_million: cost[:output_audio]
366
+ }.compact
367
+
368
+ pricing = {}
369
+ pricing[:text_tokens] = { standard: text_standard } if text_standard.any?
370
+ pricing[:audio_tokens] = { standard: audio_standard } if audio_standard.any?
371
+ pricing
372
+ end
373
+
374
+ def models_dev_metadata(model_data, provider_key)
375
+ metadata = {
376
+ source: 'models.dev',
377
+ provider_id: provider_key,
378
+ open_weights: model_data[:open_weights],
379
+ attachment: model_data[:attachment],
380
+ temperature: model_data[:temperature],
381
+ last_updated: model_data[:last_updated],
382
+ status: model_data[:status],
383
+ interleaved: model_data[:interleaved],
384
+ cost: model_data[:cost],
385
+ limit: model_data[:limit],
386
+ knowledge: model_data[:knowledge]
387
+ }
388
+ metadata.compact
389
+ end
390
+
391
+ def normalize_models_dev_modalities(modalities)
392
+ normalized = { input: [], output: [] }
393
+ return normalized unless modalities
394
+
395
+ normalized[:input] = Array(modalities[:input]).compact
396
+ normalized[:output] = Array(modalities[:output]).compact
397
+ normalized
398
+ end
399
+
400
+ def normalize_models_dev_knowledge(value)
401
+ return if value.nil?
402
+ return value if value.is_a?(Date)
403
+
404
+ Date.parse(value.to_s)
405
+ rescue ArgumentError
406
+ nil
407
+ end
154
408
  end
155
409
 
156
410
  def initialize(models = nil)
157
- @models = models || self.class.load_models
411
+ @models = self.class.filter_models(models || self.class.load_models)
158
412
  end
159
413
 
160
414
  def load_from_json!(file = RubyLLM.config.model_registry_file)
@@ -217,15 +471,44 @@ module RubyLLM
217
471
 
218
472
  def find_with_provider(model_id, provider)
219
473
  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 } ||
474
+ resolved_id = resolve_bedrock_region_id(resolved_id) if provider.to_s == 'bedrock'
475
+ all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
476
+ all.find { |m| m.id == model_id && m.provider == provider.to_s } ||
222
477
  raise(ModelNotFoundError, "Unknown model: #{model_id} for provider: #{provider}")
223
478
  end
224
479
 
480
+ def resolve_bedrock_region_id(model_id)
481
+ region = RubyLLM.config.bedrock_region.to_s
482
+ return model_id if region.empty?
483
+
484
+ candidate_id = Providers::Bedrock::Models.with_region_prefix(model_id, region)
485
+ return model_id if candidate_id == model_id
486
+
487
+ candidate = all.find { |m| m.provider == 'bedrock' && m.id == candidate_id }
488
+ return model_id unless candidate
489
+
490
+ inference_types = Array(candidate.metadata[:inference_types] || candidate.metadata['inference_types'])
491
+ Providers::Bedrock::Models.normalize_inference_profile_id(model_id, inference_types, region)
492
+ end
493
+
225
494
  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}")
495
+ exact_matches = all.select { |m| m.id == model_id }
496
+ return preferred_match(exact_matches) if exact_matches.any?
497
+
498
+ resolved_id = Aliases.resolve(model_id)
499
+ alias_matches = all.select { |m| m.id == resolved_id }
500
+ return preferred_match(alias_matches) if alias_matches.any?
501
+
502
+ raise(ModelNotFoundError, "Unknown model: #{model_id}")
503
+ end
504
+
505
+ def preferred_match(candidates)
506
+ return candidates.first if candidates.size == 1
507
+
508
+ candidates.min_by do |model|
509
+ index = PROVIDER_PREFERENCE.index(model.provider)
510
+ index || PROVIDER_PREFERENCE.length
511
+ end
229
512
  end
230
513
  end
231
514
  end
@@ -55,7 +55,7 @@
55
55
  "type": "array",
56
56
  "items": {
57
57
  "type": "string",
58
- "enum": ["text", "image", "audio", "embeddings", "moderation"]
58
+ "enum": ["text", "image", "audio", "video", "embeddings", "moderation"]
59
59
  },
60
60
  "uniqueItems": true,
61
61
  "description": "Supported output modalities"
@@ -165,4 +165,4 @@
165
165
  }
166
166
  }
167
167
  }
168
- }
168
+ }
@@ -37,17 +37,21 @@ module RubyLLM
37
37
  self.class.configuration_requirements
38
38
  end
39
39
 
40
- def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, &) # rubocop:disable Metrics/ParameterLists
40
+ # rubocop:disable Metrics/ParameterLists
41
+ def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil,
42
+ tool_prefs: nil, &)
41
43
  normalized_temperature = maybe_normalize_temperature(temperature, model)
42
44
 
43
45
  payload = Utils.deep_merge(
44
46
  render_payload(
45
47
  messages,
46
48
  tools: tools,
49
+ tool_prefs: tool_prefs,
47
50
  temperature: normalized_temperature,
48
51
  model: model,
49
52
  stream: block_given?,
50
- schema: schema
53
+ schema: schema,
54
+ thinking: thinking
51
55
  ),
52
56
  params
53
57
  )
@@ -58,6 +62,7 @@ module RubyLLM
58
62
  sync_response @connection, payload, headers
59
63
  end
60
64
  end
65
+ # rubocop:enable Metrics/ParameterLists
61
66
 
62
67
  def list_models
63
68
  response = @connection.get models_url
@@ -101,16 +106,24 @@ module RubyLLM
101
106
  self.class.remote?
102
107
  end
103
108
 
109
+ def assume_models_exist?
110
+ self.class.assume_models_exist?
111
+ end
112
+
104
113
  def parse_error(response)
105
114
  return if response.body.empty?
106
115
 
107
116
  body = try_parse_json(response.body)
108
117
  case body
109
118
  when Hash
119
+ error = body['error']
120
+ return error if error.is_a?(String)
121
+
110
122
  body.dig('error', 'message')
111
123
  when Array
112
124
  body.map do |part|
113
- part.dig('error', 'message')
125
+ error = part['error']
126
+ error.is_a?(String) ? error : part.dig('error', 'message')
114
127
  end.join('. ')
115
128
  else
116
129
  body
@@ -144,13 +157,17 @@ module RubyLLM
144
157
  end
145
158
 
146
159
  def capabilities
147
- raise NotImplementedError
160
+ nil
148
161
  end
149
162
 
150
163
  def configuration_requirements
151
164
  []
152
165
  end
153
166
 
167
+ def configuration_options
168
+ []
169
+ end
170
+
154
171
  def local?
155
172
  false
156
173
  end
@@ -159,12 +176,17 @@ module RubyLLM
159
176
  !local?
160
177
  end
161
178
 
179
+ def assume_models_exist?
180
+ false
181
+ end
182
+
162
183
  def configured?(config)
163
184
  configuration_requirements.all? { |req| config.send(req) }
164
185
  end
165
186
 
166
187
  def register(name, provider_class)
167
188
  providers[name.to_sym] = provider_class
189
+ RubyLLM::Configuration.register_provider_options(provider_class.configuration_options)
168
190
  end
169
191
 
170
192
  def resolve(name)
@@ -3,130 +3,16 @@
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
10
+ def supports_tool_choice?(_model_id)
11
+ true
12
12
  end
13
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-3/)
35
- end
36
-
37
- def supports_json_mode?(model_id)
38
- model_id.match?(/claude-3/)
39
- end
40
-
41
- def supports_extended_thinking?(model_id)
42
- model_id.match?(/claude-3-7-sonnet/)
43
- end
44
-
45
- def model_family(model_id)
46
- case model_id
47
- when /claude-3-7-sonnet/ then 'claude-3-7-sonnet'
48
- when /claude-3-5-sonnet/ then 'claude-3-5-sonnet'
49
- when /claude-3-5-haiku/ then 'claude-3-5-haiku'
50
- when /claude-3-opus/ then 'claude-3-opus'
51
- when /claude-3-sonnet/ then 'claude-3-sonnet'
52
- when /claude-3-haiku/ then 'claude-3-haiku'
53
- else 'claude-2'
54
- end
55
- end
56
-
57
- def model_type(_)
58
- 'chat'
59
- end
60
-
61
- PRICES = {
62
- 'claude-3-7-sonnet': { input: 3.0, output: 15.0 },
63
- 'claude-3-5-sonnet': { input: 3.0, output: 15.0 },
64
- 'claude-3-5-haiku': { input: 0.80, output: 4.0 },
65
- 'claude-3-opus': { input: 15.0, output: 75.0 },
66
- 'claude-3-haiku': { input: 0.25, output: 1.25 },
67
- 'claude-2': { input: 3.0, output: 15.0 }
68
- }.freeze
69
-
70
- def default_input_price
71
- 3.0
72
- end
73
-
74
- def default_output_price
75
- 15.0
76
- end
77
-
78
- def modalities_for(model_id)
79
- modalities = {
80
- input: ['text'],
81
- output: ['text']
82
- }
83
-
84
- unless model_id.match?(/claude-[12]/)
85
- modalities[:input] << 'image'
86
- modalities[:input] << 'pdf'
87
- end
88
-
89
- modalities
90
- end
91
-
92
- def capabilities_for(model_id)
93
- capabilities = ['streaming']
94
-
95
- if model_id.match?(/claude-3/)
96
- capabilities << 'function_calling'
97
- capabilities << 'batch'
98
- end
99
-
100
- capabilities << 'reasoning' if model_id.match?(/claude-3-7|-4/)
101
- capabilities << 'citations' if model_id.match?(/claude-3\.5|claude-3-7/)
102
- capabilities
103
- end
104
-
105
- def pricing_for(model_id)
106
- family = model_family(model_id)
107
- prices = PRICES.fetch(family.to_sym, { input: default_input_price, output: default_output_price })
108
-
109
- standard_pricing = {
110
- input_per_million: prices[:input],
111
- output_per_million: prices[:output]
112
- }
113
-
114
- batch_pricing = {
115
- input_per_million: prices[:input] * 0.5,
116
- output_per_million: prices[:output] * 0.5
117
- }
118
-
119
- if model_id.match?(/claude-3-7/)
120
- standard_pricing[:reasoning_output_per_million] = prices[:output] * 2.5
121
- batch_pricing[:reasoning_output_per_million] = prices[:output] * 1.25
122
- end
123
-
124
- {
125
- text_tokens: {
126
- standard: standard_pricing,
127
- batch: batch_pricing
128
- }
129
- }
14
+ def supports_tool_parallel_control?(_model_id)
15
+ true
130
16
  end
131
17
  end
132
18
  end