ruby_llm 1.14.1 → 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 +6 -7
- data/lib/generators/ruby_llm/generator_helpers.rb +8 -0
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +1 -1
- data/lib/generators/ruby_llm/tool/templates/tool.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 +4 -26
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +123 -29
- data/lib/ruby_llm/active_record/chat_methods.rb +41 -24
- data/lib/ruby_llm/active_record/message_methods.rb +87 -4
- data/lib/ruby_llm/active_record/model_methods.rb +7 -9
- data/lib/ruby_llm/active_record/payload_helpers.rb +3 -0
- data/lib/ruby_llm/active_record/tool_call_methods.rb +3 -0
- data/lib/ruby_llm/agent.rb +4 -2
- data/lib/ruby_llm/aliases.json +108 -75
- data/lib/ruby_llm/aliases.rb +3 -0
- data/lib/ruby_llm/attachment.rb +41 -40
- data/lib/ruby_llm/chat.rb +229 -59
- 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/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 +11 -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 +53 -2
- 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 +17817 -13942
- data/lib/ruby_llm/models.rb +97 -31
- data/lib/ruby_llm/models_schema.json +3 -0
- data/lib/ruby_llm/provider.rb +20 -4
- 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 +32 -3
- 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 +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 +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 +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/streaming.rb +6 -1
- data/lib/ruby_llm/providers/gemini/tools.rb +5 -1
- 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 +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 +2 -2
- data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
- data/lib/ruby_llm/providers/openai/capabilities.rb +82 -12
- 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/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/openrouter/chat.rb +36 -8
- data/lib/ruby_llm/providers/openrouter/images.rb +2 -2
- 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/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 +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 +50 -23
- metadata +35 -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,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
|
|
@@ -356,7 +397,8 @@ module RubyLLM
|
|
|
356
397
|
text_standard = {
|
|
357
398
|
input_per_million: cost[:input],
|
|
358
399
|
output_per_million: cost[:output],
|
|
359
|
-
|
|
400
|
+
cache_read_input_per_million: cost[:cache_read],
|
|
401
|
+
cache_write_input_per_million: cost[:cache_write],
|
|
360
402
|
reasoning_output_per_million: cost[:reasoning]
|
|
361
403
|
}.compact
|
|
362
404
|
|
|
@@ -381,6 +423,7 @@ module RubyLLM
|
|
|
381
423
|
last_updated: model_data[:last_updated],
|
|
382
424
|
status: model_data[:status],
|
|
383
425
|
interleaved: model_data[:interleaved],
|
|
426
|
+
reasoning_options: model_data[:reasoning_options],
|
|
384
427
|
cost: model_data[:cost],
|
|
385
428
|
limit: model_data[:limit],
|
|
386
429
|
knowledge: model_data[:knowledge]
|
|
@@ -392,8 +435,8 @@ module RubyLLM
|
|
|
392
435
|
normalized = { input: [], output: [] }
|
|
393
436
|
return normalized unless modalities
|
|
394
437
|
|
|
395
|
-
normalized[:input] = Array(modalities[:input]).compact
|
|
396
|
-
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
|
|
397
440
|
normalized
|
|
398
441
|
end
|
|
399
442
|
|
|
@@ -411,10 +454,19 @@ module RubyLLM
|
|
|
411
454
|
@models = self.class.filter_models(models || self.class.load_models)
|
|
412
455
|
end
|
|
413
456
|
|
|
457
|
+
# Replaces this registry instance with models loaded from JSON.
|
|
414
458
|
def load_from_json!(file = RubyLLM.config.model_registry_file)
|
|
415
459
|
@models = self.class.read_from_json(file)
|
|
416
460
|
end
|
|
417
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.
|
|
418
470
|
def save_to_json(file = RubyLLM.config.model_registry_file)
|
|
419
471
|
File.write(file, JSON.pretty_generate(all.map(&:to_h)))
|
|
420
472
|
end
|
|
@@ -474,7 +526,7 @@ module RubyLLM
|
|
|
474
526
|
resolved_id = resolve_bedrock_region_id(resolved_id) if provider.to_s == 'bedrock'
|
|
475
527
|
all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
|
|
476
528
|
all.find { |m| m.id == model_id && m.provider == provider.to_s } ||
|
|
477
|
-
|
|
529
|
+
raise_model_not_found(model_id, provider: provider)
|
|
478
530
|
end
|
|
479
531
|
|
|
480
532
|
def resolve_bedrock_region_id(model_id)
|
|
@@ -499,7 +551,21 @@ module RubyLLM
|
|
|
499
551
|
alias_matches = all.select { |m| m.id == resolved_id }
|
|
500
552
|
return preferred_match(alias_matches) if alias_matches.any?
|
|
501
553
|
|
|
502
|
-
|
|
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."
|
|
503
569
|
end
|
|
504
570
|
|
|
505
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
|
|
|
@@ -225,7 +229,17 @@ module RubyLLM
|
|
|
225
229
|
|
|
226
230
|
private
|
|
227
231
|
|
|
232
|
+
def validate_paint_inputs!(with:, mask:)
|
|
233
|
+
return if with.nil? && mask.nil?
|
|
234
|
+
|
|
235
|
+
raise UnsupportedAttachmentError, 'image reference'
|
|
236
|
+
end
|
|
237
|
+
|
|
228
238
|
def build_audio_file_part(file_path)
|
|
239
|
+
require 'faraday/multipart'
|
|
240
|
+
require 'marcel'
|
|
241
|
+
require 'pathname'
|
|
242
|
+
|
|
229
243
|
expanded_path = File.expand_path(file_path)
|
|
230
244
|
mime_type = Marcel::MimeType.for(Pathname.new(expanded_path))
|
|
231
245
|
|
|
@@ -248,7 +262,9 @@ module RubyLLM
|
|
|
248
262
|
missing = configuration_requirements.reject { |req| @config.send(req) }
|
|
249
263
|
return if missing.empty?
|
|
250
264
|
|
|
251
|
-
raise ConfigurationError,
|
|
265
|
+
raise ConfigurationError,
|
|
266
|
+
"Missing configuration for #{name}: #{missing.join(', ')}. " \
|
|
267
|
+
'Set these keys on RubyLLM.config before using this provider.'
|
|
252
268
|
end
|
|
253
269
|
|
|
254
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,11 +44,23 @@ 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)
|
|
57
|
+
content = msg.content
|
|
58
|
+
content = '(no output)' if content.nil? || (content.respond_to?(:empty?) && content.empty?)
|
|
59
|
+
|
|
48
60
|
{
|
|
49
61
|
type: 'tool_result',
|
|
50
62
|
tool_use_id: msg.tool_call_id,
|
|
51
|
-
content: Media.format_content(
|
|
63
|
+
content: Media.format_content(content)
|
|
52
64
|
}
|
|
53
65
|
end
|
|
54
66
|
|
|
@@ -69,12 +81,29 @@ module RubyLLM
|
|
|
69
81
|
|
|
70
82
|
def extract_tool_calls(data)
|
|
71
83
|
if json_delta?(data)
|
|
72
|
-
|
|
84
|
+
extract_tool_call_delta(data)
|
|
85
|
+
elsif content_block_start?(data)
|
|
86
|
+
extract_tool_call_start(data)
|
|
73
87
|
else
|
|
74
88
|
parse_tool_calls(data['content_block'])
|
|
75
89
|
end
|
|
76
90
|
end
|
|
77
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
|
+
|
|
78
107
|
def parse_tool_calls(content_blocks)
|
|
79
108
|
return nil if content_blocks.nil?
|
|
80
109
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module RubyLLM
|
|
4
6
|
module Providers
|
|
5
7
|
class Bedrock
|
|
@@ -56,7 +58,7 @@ module RubyLLM
|
|
|
56
58
|
content: parse_text_content(content_blocks),
|
|
57
59
|
thinking: Thinking.build(text: thinking_text, signature: thinking_signature),
|
|
58
60
|
tool_calls: parse_tool_calls(content_blocks),
|
|
59
|
-
input_tokens: usage
|
|
61
|
+
input_tokens: input_tokens(usage),
|
|
60
62
|
output_tokens: usage['outputTokens'],
|
|
61
63
|
cached_tokens: usage['cacheReadInputTokens'],
|
|
62
64
|
cache_creation_tokens: usage['cacheWriteInputTokens'],
|
|
@@ -66,6 +68,13 @@ module RubyLLM
|
|
|
66
68
|
)
|
|
67
69
|
end
|
|
68
70
|
|
|
71
|
+
def input_tokens(usage)
|
|
72
|
+
input_tokens = usage['inputTokens']
|
|
73
|
+
return unless input_tokens
|
|
74
|
+
|
|
75
|
+
[input_tokens.to_i - usage['cacheReadInputTokens'].to_i - usage['cacheWriteInputTokens'].to_i, 0].max
|
|
76
|
+
end
|
|
77
|
+
|
|
69
78
|
def render_messages(messages)
|
|
70
79
|
rendered = []
|
|
71
80
|
tool_result_blocks = []
|
|
@@ -154,19 +163,23 @@ module RubyLLM
|
|
|
154
163
|
|
|
155
164
|
def render_tool_result_content(content)
|
|
156
165
|
return render_raw_tool_result_content(content.value) if content.is_a?(RubyLLM::Content::Raw)
|
|
166
|
+
return [{ json: content }] if content.is_a?(Hash) || content.is_a?(Array)
|
|
167
|
+
return render_content_tool_result_content(content) if content.is_a?(RubyLLM::Content)
|
|
157
168
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
169
|
+
[text_tool_result_block(content)]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def render_content_tool_result_content(content)
|
|
173
|
+
blocks = []
|
|
174
|
+
blocks << text_tool_result_block(content.text) unless content.text.to_s.empty?
|
|
175
|
+
content.attachments.each { |attachment| blocks << text_tool_result_block(attachment.for_llm) }
|
|
176
|
+
blocks.empty? ? [text_tool_result_block(nil)] : blocks
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def text_tool_result_block(text)
|
|
180
|
+
text = text.to_s
|
|
181
|
+
text = '(no output)' if text.empty?
|
|
182
|
+
{ text: text }
|
|
170
183
|
end
|
|
171
184
|
|
|
172
185
|
def render_raw_tool_result_content(raw_value)
|
|
@@ -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
|