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.
- checksums.yaml +4 -4
- data/README.md +16 -13
- data/lib/generators/ruby_llm/agent/agent_generator.rb +36 -0
- data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -0
- data/lib/generators/ruby_llm/agent/templates/instructions.txt.erb.tt +0 -0
- data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +110 -41
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +14 -15
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -11
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
- data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
- data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +2 -2
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +19 -7
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +5 -3
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +1 -1
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -7
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +5 -7
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +11 -12
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +27 -17
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -4
- data/lib/generators/ruby_llm/generator_helpers.rb +41 -17
- data/lib/generators/ruby_llm/install/install_generator.rb +21 -18
- data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +3 -4
- data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +1 -1
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +3 -3
- data/lib/generators/ruby_llm/schema/schema_generator.rb +26 -0
- data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +2 -0
- data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +9 -0
- data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +13 -0
- data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +13 -0
- data/lib/generators/ruby_llm/tool/tool_generator.rb +96 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +1 -1
- data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +5 -7
- data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
- data/lib/ruby_llm/active_record/acts_as.rb +6 -26
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +125 -29
- data/lib/ruby_llm/active_record/chat_methods.rb +50 -26
- data/lib/ruby_llm/active_record/message_methods.rb +104 -4
- data/lib/ruby_llm/active_record/model_methods.rb +8 -10
- data/lib/ruby_llm/active_record/payload_helpers.rb +29 -0
- data/lib/ruby_llm/active_record/tool_call_methods.rb +18 -0
- data/lib/ruby_llm/agent.rb +15 -2
- data/lib/ruby_llm/aliases.json +141 -89
- data/lib/ruby_llm/aliases.rb +3 -0
- data/lib/ruby_llm/attachment.rb +44 -40
- data/lib/ruby_llm/chat.rb +229 -59
- data/lib/ruby_llm/configuration.rb +69 -75
- data/lib/ruby_llm/connection.rb +37 -10
- data/lib/ruby_llm/content.rb +15 -1
- data/lib/ruby_llm/cost.rb +224 -0
- data/lib/ruby_llm/deprecator.rb +24 -0
- data/lib/ruby_llm/embedding.rb +31 -1
- data/lib/ruby_llm/error.rb +16 -75
- data/lib/ruby_llm/error_middleware.rb +81 -0
- data/lib/ruby_llm/image.rb +39 -4
- data/lib/ruby_llm/instrumentation.rb +36 -0
- data/lib/ruby_llm/message.rb +20 -0
- data/lib/ruby_llm/mime_type.rb +25 -0
- data/lib/ruby_llm/model/info.rb +67 -14
- data/lib/ruby_llm/model/pricing.rb +19 -9
- data/lib/ruby_llm/model/pricing_category.rb +13 -2
- data/lib/ruby_llm/model/pricing_tier.rb +20 -9
- data/lib/ruby_llm/model_registry.rb +39 -0
- data/lib/ruby_llm/models.json +21796 -20601
- data/lib/ruby_llm/models.rb +107 -34
- data/lib/ruby_llm/models_schema.json +3 -0
- data/lib/ruby_llm/provider.rb +25 -4
- data/lib/ruby_llm/providers/anthropic/capabilities.rb +1 -133
- data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
- data/lib/ruby_llm/providers/anthropic/models.rb +4 -8
- data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
- data/lib/ruby_llm/providers/anthropic/tools.rb +32 -3
- data/lib/ruby_llm/providers/anthropic.rb +4 -0
- data/lib/ruby_llm/providers/azure/media.rb +1 -1
- data/lib/ruby_llm/providers/azure.rb +4 -0
- data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +26 -13
- data/lib/ruby_llm/providers/bedrock/media.rb +21 -3
- data/lib/ruby_llm/providers/bedrock/models.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/streaming.rb +10 -1
- data/lib/ruby_llm/providers/bedrock.rb +5 -1
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +28 -103
- data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
- data/lib/ruby_llm/providers/deepseek.rb +4 -0
- data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -215
- data/lib/ruby_llm/providers/gemini/chat.rb +10 -4
- data/lib/ruby_llm/providers/gemini/images.rb +2 -2
- data/lib/ruby_llm/providers/gemini/media.rb +16 -9
- data/lib/ruby_llm/providers/gemini/models.rb +2 -4
- data/lib/ruby_llm/providers/gemini/streaming.rb +6 -1
- data/lib/ruby_llm/providers/gemini/tools.rb +5 -1
- data/lib/ruby_llm/providers/gemini.rb +4 -0
- data/lib/ruby_llm/providers/gpustack/chat.rb +8 -1
- data/lib/ruby_llm/providers/gpustack/models.rb +2 -0
- data/lib/ruby_llm/providers/gpustack.rb +4 -0
- data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -2
- data/lib/ruby_llm/providers/mistral/chat.rb +56 -5
- data/lib/ruby_llm/providers/mistral/media.rb +55 -0
- data/lib/ruby_llm/providers/mistral/models.rb +2 -0
- data/lib/ruby_llm/providers/mistral.rb +5 -1
- data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
- data/lib/ruby_llm/providers/ollama.rb +4 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +157 -195
- data/lib/ruby_llm/providers/openai/chat.rb +61 -7
- data/lib/ruby_llm/providers/openai/images.rb +58 -6
- data/lib/ruby_llm/providers/openai/media.rb +40 -16
- data/lib/ruby_llm/providers/openai/models.rb +2 -4
- data/lib/ruby_llm/providers/openai/streaming.rb +7 -6
- data/lib/ruby_llm/providers/openai/tools.rb +2 -0
- data/lib/ruby_llm/providers/openai/transcription.rb +1 -0
- data/lib/ruby_llm/providers/openai.rb +10 -0
- data/lib/ruby_llm/providers/openrouter/chat.rb +36 -8
- data/lib/ruby_llm/providers/openrouter/images.rb +3 -3
- data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
- data/lib/ruby_llm/providers/openrouter/streaming.rb +5 -6
- data/lib/ruby_llm/providers/openrouter.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
- data/lib/ruby_llm/providers/perplexity/chat.rb +11 -0
- data/lib/ruby_llm/providers/perplexity/media.rb +62 -0
- data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
- data/lib/ruby_llm/providers/perplexity.rb +5 -1
- data/lib/ruby_llm/providers/vertexai.rb +8 -0
- data/lib/ruby_llm/providers/xai/chat.rb +9 -0
- data/lib/ruby_llm/providers/xai/models.rb +15 -27
- data/lib/ruby_llm/providers/xai.rb +5 -1
- data/lib/ruby_llm/railtie.rb +11 -1
- data/lib/ruby_llm/stream_accumulator.rb +45 -30
- data/lib/ruby_llm/streaming.rb +4 -0
- data/lib/ruby_llm/tokens.rb +8 -0
- data/lib/ruby_llm/tool.rb +24 -7
- data/lib/ruby_llm/tool_concurrency.rb +105 -0
- data/lib/ruby_llm/transcription.rb +2 -1
- data/lib/ruby_llm/utils.rb +39 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm.rb +11 -6
- data/lib/tasks/models.rake +45 -16
- data/lib/tasks/release.rake +51 -24
- data/lib/tasks/ruby_llm.rake +6 -5
- data/lib/tasks/vcr.rake +1 -1
- metadata +81 -21
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
data/lib/ruby_llm/models.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
105
|
+
provider_fetch = fetch_provider_models(remote_only: remote_only)
|
|
106
|
+
log_provider_fetch(provider_fetch)
|
|
60
107
|
|
|
61
|
-
|
|
62
|
-
|
|
108
|
+
models_dev_fetch = fetch_models_dev_models(existing_models)
|
|
109
|
+
log_models_dev_fetch(models_dev_fetch)
|
|
63
110
|
|
|
64
|
-
|
|
65
|
-
|
|
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 ||=
|
|
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] ||
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
data/lib/ruby_llm/provider.rb
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
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,
|
|
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
|
-
#
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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,
|
|
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
|