ruby_llm 1.15.0 → 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 +5 -4
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +1 -1
- data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +3 -3
- data/lib/ruby_llm/active_record/acts_as.rb +1 -26
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +71 -4
- data/lib/ruby_llm/active_record/chat_methods.rb +2 -2
- data/lib/ruby_llm/active_record/message_methods.rb +70 -3
- data/lib/ruby_llm/agent.rb +1 -0
- data/lib/ruby_llm/aliases.json +78 -75
- data/lib/ruby_llm/aliases.rb +3 -0
- data/lib/ruby_llm/attachment.rb +34 -17
- data/lib/ruby_llm/chat.rb +176 -47
- data/lib/ruby_llm/configuration.rb +14 -1
- data/lib/ruby_llm/connection.rb +36 -7
- data/lib/ruby_llm/content.rb +15 -1
- data/lib/ruby_llm/deprecator.rb +24 -0
- data/lib/ruby_llm/embedding.rb +31 -1
- data/lib/ruby_llm/error.rb +11 -75
- data/lib/ruby_llm/error_middleware.rb +81 -0
- data/lib/ruby_llm/image.rb +2 -0
- data/lib/ruby_llm/instrumentation.rb +36 -0
- data/lib/ruby_llm/mime_type.rb +25 -0
- data/lib/ruby_llm/model/info.rb +36 -2
- data/lib/ruby_llm/model/pricing.rb +19 -9
- 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 +18225 -19144
- data/lib/ruby_llm/models.rb +95 -30
- data/lib/ruby_llm/provider.rb +11 -2
- data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
- data/lib/ruby_llm/providers/anthropic/models.rb +2 -0
- data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
- data/lib/ruby_llm/providers/anthropic/tools.rb +28 -2
- data/lib/ruby_llm/providers/azure/media.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +2 -0
- 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 +6 -0
- data/lib/ruby_llm/providers/bedrock.rb +2 -2
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +43 -0
- data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
- data/lib/ruby_llm/providers/gemini/chat.rb +2 -3
- data/lib/ruby_llm/providers/gemini/media.rb +16 -9
- data/lib/ruby_llm/providers/gemini/streaming.rb +2 -0
- data/lib/ruby_llm/providers/gemini/tools.rb +2 -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/mistral/capabilities.rb +1 -1
- data/lib/ruby_llm/providers/mistral/chat.rb +1 -1
- 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 +2 -2
- data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
- data/lib/ruby_llm/providers/openai/chat.rb +16 -1
- data/lib/ruby_llm/providers/openai/images.rb +9 -9
- data/lib/ruby_llm/providers/openai/media.rb +40 -16
- data/lib/ruby_llm/providers/openai/streaming.rb +2 -0
- 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/openrouter/chat.rb +6 -2
- 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.rb +2 -2
- data/lib/ruby_llm/providers/vertexai.rb +5 -1
- 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 +2 -2
- data/lib/ruby_llm/railtie.rb +5 -1
- data/lib/ruby_llm/stream_accumulator.rb +45 -30
- data/lib/ruby_llm/streaming.rb +4 -0
- 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 +9 -2
- data/lib/tasks/models.rake +32 -4
- data/lib/tasks/release.rake +50 -23
- metadata +17 -10
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,6 +73,14 @@ 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
|
|
|
@@ -53,17 +92,26 @@ module RubyLLM
|
|
|
53
92
|
[]
|
|
54
93
|
end
|
|
55
94
|
|
|
95
|
+
def read_from_database
|
|
96
|
+
ModelRegistry::ActiveRecordSource.new.read
|
|
97
|
+
end
|
|
98
|
+
|
|
56
99
|
def refresh!(remote_only: false)
|
|
57
|
-
|
|
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
|
|
58
104
|
|
|
59
|
-
|
|
60
|
-
|
|
105
|
+
provider_fetch = fetch_provider_models(remote_only: remote_only)
|
|
106
|
+
log_provider_fetch(provider_fetch)
|
|
61
107
|
|
|
62
|
-
|
|
63
|
-
|
|
108
|
+
models_dev_fetch = fetch_models_dev_models(existing_models)
|
|
109
|
+
log_models_dev_fetch(models_dev_fetch)
|
|
64
110
|
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
67
115
|
end
|
|
68
116
|
|
|
69
117
|
def fetch_provider_models(remote_only: true) # rubocop:disable Metrics/PerceivedComplexity
|
|
@@ -115,7 +163,7 @@ module RubyLLM
|
|
|
115
163
|
if assume_exists
|
|
116
164
|
raise ArgumentError, 'Provider must be specified if assume_exists is true' unless provider
|
|
117
165
|
|
|
118
|
-
provider_class ||=
|
|
166
|
+
provider_class ||= raise_unknown_provider(provider)
|
|
119
167
|
provider_instance = provider_class.new(config)
|
|
120
168
|
|
|
121
169
|
model = if provider_instance.local?
|
|
@@ -129,25 +177,12 @@ module RubyLLM
|
|
|
129
177
|
model ||= Model::Info.default(model_id, provider_instance.slug)
|
|
130
178
|
else
|
|
131
179
|
model = Models.find model_id, provider
|
|
132
|
-
provider_class = Provider.providers[model.provider.to_sym] ||
|
|
133
|
-
"Unknown provider: #{model.provider}")
|
|
180
|
+
provider_class = Provider.providers[model.provider.to_sym] || raise_unknown_provider(model.provider)
|
|
134
181
|
provider_instance = provider_class.new(config)
|
|
135
182
|
end
|
|
136
183
|
[model, provider_instance]
|
|
137
184
|
end
|
|
138
185
|
|
|
139
|
-
def method_missing(method, ...)
|
|
140
|
-
if instance.respond_to?(method)
|
|
141
|
-
instance.send(method, ...)
|
|
142
|
-
else
|
|
143
|
-
super
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def respond_to_missing?(method, include_private = false)
|
|
148
|
-
instance.respond_to?(method, include_private) || super
|
|
149
|
-
end
|
|
150
|
-
|
|
151
186
|
def fetch_models_dev_models(existing_models) # rubocop:disable Metrics/PerceivedComplexity
|
|
152
187
|
RubyLLM.logger.info 'Fetching models from models.dev API...'
|
|
153
188
|
|
|
@@ -181,6 +216,11 @@ module RubyLLM
|
|
|
181
216
|
existing_models
|
|
182
217
|
end
|
|
183
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
|
+
|
|
184
224
|
def log_provider_fetch(provider_fetch)
|
|
185
225
|
RubyLLM.logger.info "Fetching models from providers: #{provider_fetch[:configured_names].join(', ')}"
|
|
186
226
|
provider_fetch[:failed].each do |failure|
|
|
@@ -288,7 +328,8 @@ module RubyLLM
|
|
|
288
328
|
data[:modalities] = provider_model.modalities.to_h if blank_value?(data[:modalities])
|
|
289
329
|
data[:pricing] = provider_model.pricing.to_h if blank_value?(data[:pricing])
|
|
290
330
|
data[:metadata] = provider_model.metadata.merge(data[:metadata] || {})
|
|
291
|
-
|
|
331
|
+
provider_capabilities = provider_model.capabilities - MODELS_DEV_AUTHORITY_CAPABILITIES
|
|
332
|
+
data[:capabilities] = (models_dev_model.capabilities + provider_capabilities).uniq
|
|
292
333
|
normalize_embedding_modalities(data)
|
|
293
334
|
Model::Info.new(data)
|
|
294
335
|
end
|
|
@@ -327,7 +368,7 @@ module RubyLLM
|
|
|
327
368
|
name: model_data[:name] || model_data[:id],
|
|
328
369
|
provider: provider_slug,
|
|
329
370
|
family: model_data[:family],
|
|
330
|
-
created_at: created_date
|
|
371
|
+
created_at: Utils.iso_date_prefix_to_utc_midnight_string(created_date),
|
|
331
372
|
context_window: model_data.dig(:limit, :context),
|
|
332
373
|
max_output_tokens: model_data.dig(:limit, :output),
|
|
333
374
|
knowledge_cutoff: normalize_models_dev_knowledge(model_data[:knowledge]),
|
|
@@ -345,7 +386,7 @@ module RubyLLM
|
|
|
345
386
|
capabilities = []
|
|
346
387
|
capabilities << 'function_calling' if model_data[:tool_call]
|
|
347
388
|
capabilities << 'structured_output' if model_data[:structured_output]
|
|
348
|
-
capabilities << 'reasoning' if model_data[:reasoning]
|
|
389
|
+
capabilities << 'reasoning' if model_data[:reasoning] || model_data[:reasoning_options]
|
|
349
390
|
capabilities << 'vision' if modalities[:input].intersect?(%w[image video pdf])
|
|
350
391
|
capabilities.uniq
|
|
351
392
|
end
|
|
@@ -382,6 +423,7 @@ module RubyLLM
|
|
|
382
423
|
last_updated: model_data[:last_updated],
|
|
383
424
|
status: model_data[:status],
|
|
384
425
|
interleaved: model_data[:interleaved],
|
|
426
|
+
reasoning_options: model_data[:reasoning_options],
|
|
385
427
|
cost: model_data[:cost],
|
|
386
428
|
limit: model_data[:limit],
|
|
387
429
|
knowledge: model_data[:knowledge]
|
|
@@ -393,8 +435,8 @@ module RubyLLM
|
|
|
393
435
|
normalized = { input: [], output: [] }
|
|
394
436
|
return normalized unless modalities
|
|
395
437
|
|
|
396
|
-
normalized[:input] = Array(modalities[:input]).compact
|
|
397
|
-
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
|
|
398
440
|
normalized
|
|
399
441
|
end
|
|
400
442
|
|
|
@@ -412,10 +454,19 @@ module RubyLLM
|
|
|
412
454
|
@models = self.class.filter_models(models || self.class.load_models)
|
|
413
455
|
end
|
|
414
456
|
|
|
457
|
+
# Replaces this registry instance with models loaded from JSON.
|
|
415
458
|
def load_from_json!(file = RubyLLM.config.model_registry_file)
|
|
416
459
|
@models = self.class.read_from_json(file)
|
|
417
460
|
end
|
|
418
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.
|
|
419
470
|
def save_to_json(file = RubyLLM.config.model_registry_file)
|
|
420
471
|
File.write(file, JSON.pretty_generate(all.map(&:to_h)))
|
|
421
472
|
end
|
|
@@ -475,7 +526,7 @@ module RubyLLM
|
|
|
475
526
|
resolved_id = resolve_bedrock_region_id(resolved_id) if provider.to_s == 'bedrock'
|
|
476
527
|
all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
|
|
477
528
|
all.find { |m| m.id == model_id && m.provider == provider.to_s } ||
|
|
478
|
-
|
|
529
|
+
raise_model_not_found(model_id, provider: provider)
|
|
479
530
|
end
|
|
480
531
|
|
|
481
532
|
def resolve_bedrock_region_id(model_id)
|
|
@@ -500,7 +551,21 @@ module RubyLLM
|
|
|
500
551
|
alias_matches = all.select { |m| m.id == resolved_id }
|
|
501
552
|
return preferred_match(alias_matches) if alias_matches.any?
|
|
502
553
|
|
|
503
|
-
|
|
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."
|
|
504
569
|
end
|
|
505
570
|
|
|
506
571
|
def preferred_match(candidates)
|
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
|
|
@@ -229,10 +232,14 @@ module RubyLLM
|
|
|
229
232
|
def validate_paint_inputs!(with:, mask:)
|
|
230
233
|
return if with.nil? && mask.nil?
|
|
231
234
|
|
|
232
|
-
raise UnsupportedAttachmentError,
|
|
235
|
+
raise UnsupportedAttachmentError, 'image reference'
|
|
233
236
|
end
|
|
234
237
|
|
|
235
238
|
def build_audio_file_part(file_path)
|
|
239
|
+
require 'faraday/multipart'
|
|
240
|
+
require 'marcel'
|
|
241
|
+
require 'pathname'
|
|
242
|
+
|
|
236
243
|
expanded_path = File.expand_path(file_path)
|
|
237
244
|
mime_type = Marcel::MimeType.for(Pathname.new(expanded_path))
|
|
238
245
|
|
|
@@ -255,7 +262,9 @@ module RubyLLM
|
|
|
255
262
|
missing = configuration_requirements.reject { |req| @config.send(req) }
|
|
256
263
|
return if missing.empty?
|
|
257
264
|
|
|
258
|
-
raise ConfigurationError,
|
|
265
|
+
raise ConfigurationError,
|
|
266
|
+
"Missing configuration for #{name}: #{missing.join(', ')}. " \
|
|
267
|
+
'Set these keys on RubyLLM.config before using this provider.'
|
|
259
268
|
end
|
|
260
269
|
|
|
261
270
|
def maybe_normalize_temperature(temperature, _model)
|
|
@@ -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
|
|
@@ -16,7 +16,7 @@ module RubyLLM
|
|
|
16
16
|
|
|
17
17
|
content = []
|
|
18
18
|
|
|
19
|
-
content
|
|
19
|
+
append_formatted_content(content, msg.content) unless msg.content.nil? || msg.content.empty?
|
|
20
20
|
|
|
21
21
|
msg.tool_calls.each_value do |tool_call|
|
|
22
22
|
content << format_tool_use_block(tool_call)
|
|
@@ -44,6 +44,15 @@ module RubyLLM
|
|
|
44
44
|
}
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
def append_formatted_content(content_blocks, content)
|
|
48
|
+
formatted_content = Media.format_content(content)
|
|
49
|
+
if formatted_content.is_a?(Array)
|
|
50
|
+
content_blocks.concat(formatted_content)
|
|
51
|
+
else
|
|
52
|
+
content_blocks << formatted_content
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
47
56
|
def format_tool_result_block(msg)
|
|
48
57
|
content = msg.content
|
|
49
58
|
content = '(no output)' if content.nil? || (content.respond_to?(:empty?) && content.empty?)
|
|
@@ -72,12 +81,29 @@ module RubyLLM
|
|
|
72
81
|
|
|
73
82
|
def extract_tool_calls(data)
|
|
74
83
|
if json_delta?(data)
|
|
75
|
-
|
|
84
|
+
extract_tool_call_delta(data)
|
|
85
|
+
elsif content_block_start?(data)
|
|
86
|
+
extract_tool_call_start(data)
|
|
76
87
|
else
|
|
77
88
|
parse_tool_calls(data['content_block'])
|
|
78
89
|
end
|
|
79
90
|
end
|
|
80
91
|
|
|
92
|
+
def extract_tool_call_delta(data)
|
|
93
|
+
{ data['index'] => ToolCall.new(id: nil, name: nil, arguments: data.dig('delta', 'partial_json')) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def extract_tool_call_start(data)
|
|
97
|
+
tool_calls = parse_tool_calls(data['content_block'])
|
|
98
|
+
return tool_calls if tool_calls.nil? || data['index'].nil?
|
|
99
|
+
|
|
100
|
+
{ data['index'] => tool_calls.values.first }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def content_block_start?(data)
|
|
104
|
+
data['type'] == 'content_block_start'
|
|
105
|
+
end
|
|
106
|
+
|
|
81
107
|
def parse_tool_calls(content_blocks)
|
|
82
108
|
return nil if content_blocks.nil?
|
|
83
109
|
|
|
@@ -38,15 +38,17 @@ module RubyLLM
|
|
|
38
38
|
case attachment.type
|
|
39
39
|
when :image
|
|
40
40
|
render_image_attachment(attachment)
|
|
41
|
-
when :pdf
|
|
41
|
+
when :pdf, :document
|
|
42
42
|
render_document_attachment(attachment, used_document_names:)
|
|
43
43
|
when :text
|
|
44
|
-
|
|
44
|
+
render_text_attachment(attachment)
|
|
45
45
|
else
|
|
46
46
|
raise UnsupportedAttachmentError, attachment.mime_type
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
SUPPORTED_DOCUMENT_FORMATS = %w[pdf csv doc docx xls xlsx html txt md].freeze
|
|
51
|
+
|
|
50
52
|
def render_image_attachment(attachment)
|
|
51
53
|
{
|
|
52
54
|
image: {
|
|
@@ -58,11 +60,19 @@ module RubyLLM
|
|
|
58
60
|
}
|
|
59
61
|
end
|
|
60
62
|
|
|
63
|
+
def render_text_attachment(attachment)
|
|
64
|
+
{ text: attachment.for_llm }
|
|
65
|
+
end
|
|
66
|
+
|
|
61
67
|
def render_document_attachment(attachment, used_document_names:)
|
|
68
|
+
format = document_format(attachment)
|
|
69
|
+
|
|
70
|
+
raise UnsupportedAttachmentError, attachment.mime_type unless supported_document_format?(attachment)
|
|
71
|
+
|
|
62
72
|
document_name = unique_document_name(sanitize_document_name(attachment.filename), used_document_names)
|
|
63
73
|
{
|
|
64
74
|
document: {
|
|
65
|
-
format:
|
|
75
|
+
format: format,
|
|
66
76
|
name: document_name,
|
|
67
77
|
source: {
|
|
68
78
|
bytes: attachment.encoded
|
|
@@ -71,6 +81,14 @@ module RubyLLM
|
|
|
71
81
|
}
|
|
72
82
|
end
|
|
73
83
|
|
|
84
|
+
def supported_document_format?(attachment)
|
|
85
|
+
SUPPORTED_DOCUMENT_FORMATS.include?(document_format(attachment))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def document_format(attachment)
|
|
89
|
+
attachment.extension || attachment.format
|
|
90
|
+
end
|
|
91
|
+
|
|
74
92
|
def sanitize_document_name(filename)
|
|
75
93
|
base = File.basename(filename.to_s, '.*')
|
|
76
94
|
safe = base.gsub(/[^a-zA-Z0-9_-]/, '_')
|
|
@@ -10,7 +10,7 @@ module RubyLLM
|
|
|
10
10
|
REGION_PREFIXES = %w[global us eu ap sa ca me af il].freeze
|
|
11
11
|
|
|
12
12
|
def models_api_base
|
|
13
|
-
"https://bedrock.#{bedrock_region}.amazonaws.com"
|
|
13
|
+
@config.bedrock_api_base || "https://bedrock.#{bedrock_region}.amazonaws.com"
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def models_url
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'faraday'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
3
7
|
module RubyLLM
|
|
4
8
|
module Providers
|
|
5
9
|
class Bedrock
|
|
@@ -224,6 +228,7 @@ module RubyLLM
|
|
|
224
228
|
|
|
225
229
|
reasoning_text = reasoning_content['reasoningText'] || {}
|
|
226
230
|
return reasoning_text['text'] if reasoning_text['text']
|
|
231
|
+
return reasoning_content['text'] if reasoning_content['text']
|
|
227
232
|
return event.dig('delta', 'thinking') if event.dig('delta', 'type') == 'thinking_delta'
|
|
228
233
|
|
|
229
234
|
nil
|
|
@@ -244,6 +249,7 @@ module RubyLLM
|
|
|
244
249
|
reasoning_content = delta['reasoningContent'] || {}
|
|
245
250
|
reasoning_text = reasoning_content['reasoningText'] || {}
|
|
246
251
|
return reasoning_text['signature'] if reasoning_text['signature']
|
|
252
|
+
return reasoning_content['signature'] if reasoning_content['signature']
|
|
247
253
|
return event.dig('delta', 'signature') if event.dig('delta', 'type') == 'signature_delta'
|
|
248
254
|
|
|
249
255
|
nil
|
|
@@ -11,7 +11,7 @@ module RubyLLM
|
|
|
11
11
|
include Bedrock::Streaming
|
|
12
12
|
|
|
13
13
|
def api_base
|
|
14
|
-
"https://bedrock-runtime.#{bedrock_region}.amazonaws.com"
|
|
14
|
+
@config.bedrock_api_base || "https://bedrock-runtime.#{bedrock_region}.amazonaws.com"
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def headers
|
|
@@ -54,7 +54,7 @@ module RubyLLM
|
|
|
54
54
|
|
|
55
55
|
class << self
|
|
56
56
|
def configuration_options
|
|
57
|
-
%i[bedrock_api_key bedrock_secret_key bedrock_region bedrock_session_token]
|
|
57
|
+
%i[bedrock_api_key bedrock_secret_key bedrock_region bedrock_session_token bedrock_api_base]
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
def configuration_requirements
|
|
@@ -7,6 +7,19 @@ module RubyLLM
|
|
|
7
7
|
module Capabilities
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
+
DEFAULT_CONTEXT_WINDOW = 1_000_000
|
|
11
|
+
DEFAULT_MAX_OUTPUT_TOKENS = 384_000
|
|
12
|
+
DEFAULT_PRICES = {
|
|
13
|
+
input: 0.14,
|
|
14
|
+
output: 0.28,
|
|
15
|
+
cache_read: 0.0028
|
|
16
|
+
}.freeze
|
|
17
|
+
PRO_PRICES = {
|
|
18
|
+
input: 0.435,
|
|
19
|
+
output: 0.87,
|
|
20
|
+
cache_read: 0.003625
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
10
23
|
def supports_tool_choice?(_model_id)
|
|
11
24
|
true
|
|
12
25
|
end
|
|
@@ -14,6 +27,36 @@ module RubyLLM
|
|
|
14
27
|
def supports_tool_parallel_control?(_model_id)
|
|
15
28
|
false
|
|
16
29
|
end
|
|
30
|
+
|
|
31
|
+
def context_window_for(_model_id)
|
|
32
|
+
DEFAULT_CONTEXT_WINDOW
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def max_tokens_for(_model_id)
|
|
36
|
+
DEFAULT_MAX_OUTPUT_TOKENS
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def critical_capabilities_for(model_id)
|
|
40
|
+
v4_model = model_id.start_with?('deepseek-v4-')
|
|
41
|
+
capabilities = ['function_calling']
|
|
42
|
+
capabilities << 'structured_output' if v4_model
|
|
43
|
+
capabilities << 'reasoning' if model_id == 'deepseek-reasoner' || v4_model
|
|
44
|
+
capabilities
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def pricing_for(model_id)
|
|
48
|
+
prices = model_id == 'deepseek-v4-pro' ? PRO_PRICES : DEFAULT_PRICES
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
text_tokens: {
|
|
52
|
+
standard: {
|
|
53
|
+
input_per_million: prices[:input],
|
|
54
|
+
output_per_million: prices[:output],
|
|
55
|
+
cache_read_input_per_million: prices[:cache_read]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
end
|
|
17
60
|
end
|
|
18
61
|
end
|
|
19
62
|
end
|
|
@@ -10,6 +10,15 @@ module RubyLLM
|
|
|
10
10
|
def format_role(role)
|
|
11
11
|
role.to_s
|
|
12
12
|
end
|
|
13
|
+
|
|
14
|
+
def format_content(content)
|
|
15
|
+
OpenAI::Media.format_content(
|
|
16
|
+
content,
|
|
17
|
+
document_attachments: :none,
|
|
18
|
+
image_attachments: false,
|
|
19
|
+
audio_attachments: false
|
|
20
|
+
)
|
|
21
|
+
end
|
|
13
22
|
end
|
|
14
23
|
end
|
|
15
24
|
end
|