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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -4
  3. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +1 -1
  4. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +3 -3
  5. data/lib/ruby_llm/active_record/acts_as.rb +1 -26
  6. data/lib/ruby_llm/active_record/acts_as_legacy.rb +71 -4
  7. data/lib/ruby_llm/active_record/chat_methods.rb +2 -2
  8. data/lib/ruby_llm/active_record/message_methods.rb +70 -3
  9. data/lib/ruby_llm/agent.rb +1 -0
  10. data/lib/ruby_llm/aliases.json +78 -75
  11. data/lib/ruby_llm/aliases.rb +3 -0
  12. data/lib/ruby_llm/attachment.rb +34 -17
  13. data/lib/ruby_llm/chat.rb +176 -47
  14. data/lib/ruby_llm/configuration.rb +14 -1
  15. data/lib/ruby_llm/connection.rb +36 -7
  16. data/lib/ruby_llm/content.rb +15 -1
  17. data/lib/ruby_llm/deprecator.rb +24 -0
  18. data/lib/ruby_llm/embedding.rb +31 -1
  19. data/lib/ruby_llm/error.rb +11 -75
  20. data/lib/ruby_llm/error_middleware.rb +81 -0
  21. data/lib/ruby_llm/image.rb +2 -0
  22. data/lib/ruby_llm/instrumentation.rb +36 -0
  23. data/lib/ruby_llm/mime_type.rb +25 -0
  24. data/lib/ruby_llm/model/info.rb +36 -2
  25. data/lib/ruby_llm/model/pricing.rb +19 -9
  26. data/lib/ruby_llm/model/pricing_tier.rb +20 -9
  27. data/lib/ruby_llm/model_registry.rb +39 -0
  28. data/lib/ruby_llm/models.json +18225 -19144
  29. data/lib/ruby_llm/models.rb +95 -30
  30. data/lib/ruby_llm/provider.rb +11 -2
  31. data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
  32. data/lib/ruby_llm/providers/anthropic/models.rb +2 -0
  33. data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
  34. data/lib/ruby_llm/providers/anthropic/tools.rb +28 -2
  35. data/lib/ruby_llm/providers/azure/media.rb +1 -1
  36. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
  37. data/lib/ruby_llm/providers/bedrock/chat.rb +2 -0
  38. data/lib/ruby_llm/providers/bedrock/media.rb +21 -3
  39. data/lib/ruby_llm/providers/bedrock/models.rb +1 -1
  40. data/lib/ruby_llm/providers/bedrock/streaming.rb +6 -0
  41. data/lib/ruby_llm/providers/bedrock.rb +2 -2
  42. data/lib/ruby_llm/providers/deepseek/capabilities.rb +43 -0
  43. data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
  44. data/lib/ruby_llm/providers/gemini/chat.rb +2 -3
  45. data/lib/ruby_llm/providers/gemini/media.rb +16 -9
  46. data/lib/ruby_llm/providers/gemini/streaming.rb +2 -0
  47. data/lib/ruby_llm/providers/gemini/tools.rb +2 -0
  48. data/lib/ruby_llm/providers/gpustack/chat.rb +8 -1
  49. data/lib/ruby_llm/providers/gpustack/models.rb +2 -0
  50. data/lib/ruby_llm/providers/mistral/capabilities.rb +1 -1
  51. data/lib/ruby_llm/providers/mistral/chat.rb +1 -1
  52. data/lib/ruby_llm/providers/mistral/media.rb +55 -0
  53. data/lib/ruby_llm/providers/mistral/models.rb +2 -0
  54. data/lib/ruby_llm/providers/mistral.rb +2 -2
  55. data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
  56. data/lib/ruby_llm/providers/openai/chat.rb +16 -1
  57. data/lib/ruby_llm/providers/openai/images.rb +9 -9
  58. data/lib/ruby_llm/providers/openai/media.rb +40 -16
  59. data/lib/ruby_llm/providers/openai/streaming.rb +2 -0
  60. data/lib/ruby_llm/providers/openai/tools.rb +2 -0
  61. data/lib/ruby_llm/providers/openai/transcription.rb +1 -0
  62. data/lib/ruby_llm/providers/openrouter/chat.rb +6 -2
  63. data/lib/ruby_llm/providers/perplexity/chat.rb +11 -0
  64. data/lib/ruby_llm/providers/perplexity/media.rb +62 -0
  65. data/lib/ruby_llm/providers/perplexity.rb +2 -2
  66. data/lib/ruby_llm/providers/vertexai.rb +5 -1
  67. data/lib/ruby_llm/providers/xai/chat.rb +9 -0
  68. data/lib/ruby_llm/providers/xai/models.rb +15 -27
  69. data/lib/ruby_llm/providers/xai.rb +2 -2
  70. data/lib/ruby_llm/railtie.rb +5 -1
  71. data/lib/ruby_llm/stream_accumulator.rb +45 -30
  72. data/lib/ruby_llm/streaming.rb +4 -0
  73. data/lib/ruby_llm/tool_concurrency.rb +105 -0
  74. data/lib/ruby_llm/transcription.rb +2 -1
  75. data/lib/ruby_llm/utils.rb +39 -0
  76. data/lib/ruby_llm/version.rb +1 -1
  77. data/lib/ruby_llm.rb +9 -2
  78. data/lib/tasks/models.rake +32 -4
  79. data/lib/tasks/release.rake +50 -23
  80. metadata +17 -10
@@ -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
- existing_models = load_existing_models
100
+ # Replaces the process-wide model registry. Call save_to_json when the
101
+ # refreshed registry should also be persisted.
102
+ RubyLLM.instrument('models.refresh.ruby_llm', remote_only:) do |payload|
103
+ existing_models = load_existing_models
58
104
 
59
- provider_fetch = fetch_provider_models(remote_only: remote_only)
60
- log_provider_fetch(provider_fetch)
105
+ provider_fetch = fetch_provider_models(remote_only: remote_only)
106
+ log_provider_fetch(provider_fetch)
61
107
 
62
- models_dev_fetch = fetch_models_dev_models(existing_models)
63
- log_models_dev_fetch(models_dev_fetch)
108
+ models_dev_fetch = fetch_models_dev_models(existing_models)
109
+ log_models_dev_fetch(models_dev_fetch)
64
110
 
65
- merged_models = merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
66
- @instance = new(merged_models)
111
+ merged_models = merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
112
+ payload[:model_count] = merged_models.size
113
+ @instance = new(merged_models)
114
+ end
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 ||= raise(Error, "Unknown provider: #{provider.to_sym}")
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] || raise(Error,
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
- data[:capabilities] = (models_dev_model.capabilities + provider_model.capabilities).uniq
331
+ provider_capabilities = provider_model.capabilities - MODELS_DEV_AUTHORITY_CAPABILITIES
332
+ data[:capabilities] = (models_dev_model.capabilities + provider_capabilities).uniq
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 ? "#{created_date} 00:00:00 UTC" : nil,
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
- raise(ModelNotFoundError, "Unknown model: #{model_id} for provider: #{provider}")
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
- raise(ModelNotFoundError, "Unknown model: #{model_id}")
554
+ raise_model_not_found(model_id)
555
+ end
556
+
557
+ def raise_model_not_found(model_id, provider: nil)
558
+ message = "Unknown model: #{model_id.inspect}"
559
+ message = "#{message} for provider: #{provider.inspect}" if provider
560
+
561
+ raise ModelNotFoundError, "#{message}. #{refresh_registry_guidance}"
562
+ end
563
+
564
+ def refresh_registry_guidance
565
+ rails_model = RubyLLM.config.model_registry_class
566
+ 'If the model exists at the provider, refresh the registry with `RubyLLM.models.refresh!` ' \
567
+ 'and persist it with `RubyLLM.models.save_to_json`. ' \
568
+ "Rails model registries can call `#{rails_model}.refresh!` instead."
504
569
  end
505
570
 
506
571
  def preferred_match(candidates)
@@ -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, "#{name} does not support image references in paint"
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, "Missing configuration for #{name}: #{missing.join(', ')}"
265
+ raise ConfigurationError,
266
+ "Missing configuration for #{name}: #{missing.join(', ')}. " \
267
+ 'Set these keys on RubyLLM.config before using this provider.'
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
- if system_messages.length > 1
35
- RubyLLM.logger.warn(
36
- "Anthropic's Claude implementation only supports a single system message. " \
37
- 'Multiple system messages will be combined into one.'
38
- )
39
- end
40
-
34
+ # Anthropic's `system` parameter accepts an array of text content blocks
35
+ # (each optionally with cache_control); each :system message becomes its
36
+ # own block in the resulting array.
41
37
  system_messages.flat_map do |msg|
42
38
  content = msg.content
43
39
 
@@ -57,8 +53,7 @@ module RubyLLM
57
53
  max_tokens: model.max_tokens || 4096
58
54
  }
59
55
 
60
- thinking_payload = build_thinking_payload(thinking)
61
- payload[:thinking] = thinking_payload if thinking_payload
56
+ add_thinking_fields(payload, thinking, model)
62
57
 
63
58
  payload
64
59
  end
@@ -72,7 +67,7 @@ module RubyLLM
72
67
  end
73
68
  payload[:system] = system_content unless system_content.empty?
74
69
  payload[:temperature] = temperature unless temperature.nil?
75
- payload[:output_config] = build_output_config(schema) if schema
70
+ payload[:output_config] = payload.fetch(:output_config, {}).merge(build_output_config(schema)) if schema
76
71
  end
77
72
 
78
73
  def build_output_config(schema)
@@ -176,7 +171,7 @@ module RubyLLM
176
171
  end
177
172
 
178
173
  content_blocks = prepend_thinking_block([], msg, thinking_enabled)
179
- content_blocks << Media.format_text(msg.content) unless msg.content.nil? || msg.content.empty?
174
+ append_formatted_content(content_blocks, msg.content) unless msg.content.nil? || msg.content.empty?
180
175
 
181
176
  msg.tool_calls.each_value do |tool_call|
182
177
  content_blocks << {
@@ -235,18 +230,57 @@ module RubyLLM
235
230
  end
236
231
  end
237
232
 
238
- def build_thinking_payload(thinking)
233
+ def add_thinking_fields(payload, thinking, model)
234
+ thinking_payload = build_thinking_payload(thinking, model)
235
+ return unless thinking_payload
236
+
237
+ payload[:thinking] = thinking_payload[:thinking] if thinking_payload[:thinking]
238
+ return unless thinking_payload[:output_config]
239
+
240
+ payload[:output_config] = payload.fetch(:output_config, {}).merge(thinking_payload[:output_config])
241
+ end
242
+
243
+ def build_thinking_payload(thinking, model)
239
244
  return nil unless thinking&.enabled?
240
245
 
246
+ effort = resolve_effort(thinking)
247
+ return nil if effort == 'none'
248
+
241
249
  budget = resolve_budget(thinking)
242
- raise ArgumentError, 'Anthropic thinking requires a budget' if budget.nil?
250
+ if budget
251
+ return enabled_thinking_payload(budget) if model.reasoning_option('budget_tokens')
252
+
253
+ raise ArgumentError, "Anthropic thinking budget is not supported for #{model.id}"
254
+ end
255
+
256
+ raise ArgumentError, 'Anthropic adaptive thinking requires an effort' if effort.nil?
257
+ return adaptive_thinking_payload(effort) if model.reasoning_option('effort')
243
258
 
259
+ raise ArgumentError, "Anthropic thinking effort is not supported for #{model.id}"
260
+ end
261
+
262
+ def enabled_thinking_payload(budget)
244
263
  {
245
- type: 'enabled',
246
- budget_tokens: budget
264
+ thinking: {
265
+ type: 'enabled',
266
+ budget_tokens: budget
267
+ }
247
268
  }
248
269
  end
249
270
 
271
+ def adaptive_thinking_payload(effort)
272
+ {
273
+ thinking: { type: 'adaptive' },
274
+ output_config: { effort: effort }
275
+ }
276
+ end
277
+
278
+ def resolve_effort(thinking)
279
+ effort = thinking.respond_to?(:effort) ? thinking.effort : nil
280
+ effort = effort.to_s if effort
281
+ effort.nil? || effort.empty? ? nil : effort
282
+ end
283
+
250
284
  def resolve_budget(thinking)
251
285
  budget = thinking.respond_to?(:budget) ? thinking.budget : thinking
252
286
  budget.is_a?(Integer) ? budget : nil
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
4
+
3
5
  module RubyLLM
4
6
  module Providers
5
7
  class Anthropic
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module RubyLLM
4
6
  module Providers
5
7
  class Anthropic
@@ -16,7 +16,7 @@ module RubyLLM
16
16
 
17
17
  content = []
18
18
 
19
- content << Media.format_text(msg.content) unless msg.content.nil? || msg.content.empty?
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
- { nil => ToolCall.new(id: nil, name: nil, arguments: data.dig('delta', 'partial_json')) }
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
 
@@ -24,7 +24,7 @@ module RubyLLM
24
24
  when :text
25
25
  parts << OpenAI::Media.format_text_file(attachment)
26
26
  else
27
- raise UnsupportedAttachmentError, attachment.type
27
+ raise UnsupportedAttachmentError, attachment.mime_type
28
28
  end
29
29
  end
30
30
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
+ require 'json'
4
5
  require 'openssl'
5
6
 
6
7
  module RubyLLM
@@ -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
@@ -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
- { text: attachment.for_llm }
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: attachment.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