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.
Files changed (171) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -13
  3. data/lib/generators/ruby_llm/agent/agent_generator.rb +36 -0
  4. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -0
  5. data/lib/generators/ruby_llm/agent/templates/instructions.txt.erb.tt +0 -0
  6. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +110 -41
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +14 -15
  8. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -11
  9. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +2 -2
  10. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +1 -1
  12. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
  30. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +2 -2
  31. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +2 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +19 -7
  33. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +1 -1
  34. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +5 -3
  35. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -1
  37. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +1 -1
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -7
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +5 -7
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +11 -12
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +27 -17
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -4
  49. data/lib/generators/ruby_llm/generator_helpers.rb +41 -17
  50. data/lib/generators/ruby_llm/install/install_generator.rb +21 -18
  51. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +3 -4
  52. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +1 -1
  53. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +3 -3
  54. data/lib/generators/ruby_llm/schema/schema_generator.rb +26 -0
  55. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +2 -0
  56. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +9 -0
  57. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +13 -0
  58. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +13 -0
  59. data/lib/generators/ruby_llm/tool/tool_generator.rb +96 -0
  60. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +1 -1
  61. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  62. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  63. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +5 -7
  64. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  65. data/lib/ruby_llm/active_record/acts_as.rb +6 -26
  66. data/lib/ruby_llm/active_record/acts_as_legacy.rb +125 -29
  67. data/lib/ruby_llm/active_record/chat_methods.rb +50 -26
  68. data/lib/ruby_llm/active_record/message_methods.rb +104 -4
  69. data/lib/ruby_llm/active_record/model_methods.rb +8 -10
  70. data/lib/ruby_llm/active_record/payload_helpers.rb +29 -0
  71. data/lib/ruby_llm/active_record/tool_call_methods.rb +18 -0
  72. data/lib/ruby_llm/agent.rb +15 -2
  73. data/lib/ruby_llm/aliases.json +141 -89
  74. data/lib/ruby_llm/aliases.rb +3 -0
  75. data/lib/ruby_llm/attachment.rb +44 -40
  76. data/lib/ruby_llm/chat.rb +229 -59
  77. data/lib/ruby_llm/configuration.rb +69 -75
  78. data/lib/ruby_llm/connection.rb +37 -10
  79. data/lib/ruby_llm/content.rb +15 -1
  80. data/lib/ruby_llm/cost.rb +224 -0
  81. data/lib/ruby_llm/deprecator.rb +24 -0
  82. data/lib/ruby_llm/embedding.rb +31 -1
  83. data/lib/ruby_llm/error.rb +16 -75
  84. data/lib/ruby_llm/error_middleware.rb +81 -0
  85. data/lib/ruby_llm/image.rb +39 -4
  86. data/lib/ruby_llm/instrumentation.rb +36 -0
  87. data/lib/ruby_llm/message.rb +20 -0
  88. data/lib/ruby_llm/mime_type.rb +25 -0
  89. data/lib/ruby_llm/model/info.rb +67 -14
  90. data/lib/ruby_llm/model/pricing.rb +19 -9
  91. data/lib/ruby_llm/model/pricing_category.rb +13 -2
  92. data/lib/ruby_llm/model/pricing_tier.rb +20 -9
  93. data/lib/ruby_llm/model_registry.rb +39 -0
  94. data/lib/ruby_llm/models.json +21796 -20601
  95. data/lib/ruby_llm/models.rb +107 -34
  96. data/lib/ruby_llm/models_schema.json +3 -0
  97. data/lib/ruby_llm/provider.rb +25 -4
  98. data/lib/ruby_llm/providers/anthropic/capabilities.rb +1 -133
  99. data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
  100. data/lib/ruby_llm/providers/anthropic/models.rb +4 -8
  101. data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
  102. data/lib/ruby_llm/providers/anthropic/tools.rb +32 -3
  103. data/lib/ruby_llm/providers/anthropic.rb +4 -0
  104. data/lib/ruby_llm/providers/azure/media.rb +1 -1
  105. data/lib/ruby_llm/providers/azure.rb +4 -0
  106. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
  107. data/lib/ruby_llm/providers/bedrock/chat.rb +26 -13
  108. data/lib/ruby_llm/providers/bedrock/media.rb +21 -3
  109. data/lib/ruby_llm/providers/bedrock/models.rb +1 -1
  110. data/lib/ruby_llm/providers/bedrock/streaming.rb +10 -1
  111. data/lib/ruby_llm/providers/bedrock.rb +5 -1
  112. data/lib/ruby_llm/providers/deepseek/capabilities.rb +28 -103
  113. data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
  114. data/lib/ruby_llm/providers/deepseek.rb +4 -0
  115. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -215
  116. data/lib/ruby_llm/providers/gemini/chat.rb +10 -4
  117. data/lib/ruby_llm/providers/gemini/images.rb +2 -2
  118. data/lib/ruby_llm/providers/gemini/media.rb +16 -9
  119. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  120. data/lib/ruby_llm/providers/gemini/streaming.rb +6 -1
  121. data/lib/ruby_llm/providers/gemini/tools.rb +5 -1
  122. data/lib/ruby_llm/providers/gemini.rb +4 -0
  123. data/lib/ruby_llm/providers/gpustack/chat.rb +8 -1
  124. data/lib/ruby_llm/providers/gpustack/models.rb +2 -0
  125. data/lib/ruby_llm/providers/gpustack.rb +4 -0
  126. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -2
  127. data/lib/ruby_llm/providers/mistral/chat.rb +56 -5
  128. data/lib/ruby_llm/providers/mistral/media.rb +55 -0
  129. data/lib/ruby_llm/providers/mistral/models.rb +2 -0
  130. data/lib/ruby_llm/providers/mistral.rb +5 -1
  131. data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
  132. data/lib/ruby_llm/providers/ollama.rb +4 -0
  133. data/lib/ruby_llm/providers/openai/capabilities.rb +157 -195
  134. data/lib/ruby_llm/providers/openai/chat.rb +61 -7
  135. data/lib/ruby_llm/providers/openai/images.rb +58 -6
  136. data/lib/ruby_llm/providers/openai/media.rb +40 -16
  137. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  138. data/lib/ruby_llm/providers/openai/streaming.rb +7 -6
  139. data/lib/ruby_llm/providers/openai/tools.rb +2 -0
  140. data/lib/ruby_llm/providers/openai/transcription.rb +1 -0
  141. data/lib/ruby_llm/providers/openai.rb +10 -0
  142. data/lib/ruby_llm/providers/openrouter/chat.rb +36 -8
  143. data/lib/ruby_llm/providers/openrouter/images.rb +3 -3
  144. data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
  145. data/lib/ruby_llm/providers/openrouter/streaming.rb +5 -6
  146. data/lib/ruby_llm/providers/openrouter.rb +4 -0
  147. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  148. data/lib/ruby_llm/providers/perplexity/chat.rb +11 -0
  149. data/lib/ruby_llm/providers/perplexity/media.rb +62 -0
  150. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  151. data/lib/ruby_llm/providers/perplexity.rb +5 -1
  152. data/lib/ruby_llm/providers/vertexai.rb +8 -0
  153. data/lib/ruby_llm/providers/xai/chat.rb +9 -0
  154. data/lib/ruby_llm/providers/xai/models.rb +15 -27
  155. data/lib/ruby_llm/providers/xai.rb +5 -1
  156. data/lib/ruby_llm/railtie.rb +11 -1
  157. data/lib/ruby_llm/stream_accumulator.rb +45 -30
  158. data/lib/ruby_llm/streaming.rb +4 -0
  159. data/lib/ruby_llm/tokens.rb +8 -0
  160. data/lib/ruby_llm/tool.rb +24 -7
  161. data/lib/ruby_llm/tool_concurrency.rb +105 -0
  162. data/lib/ruby_llm/transcription.rb +2 -1
  163. data/lib/ruby_llm/utils.rb +39 -0
  164. data/lib/ruby_llm/version.rb +1 -1
  165. data/lib/ruby_llm.rb +11 -6
  166. data/lib/tasks/models.rake +45 -16
  167. data/lib/tasks/release.rake +51 -24
  168. data/lib/tasks/ruby_llm.rake +6 -5
  169. data/lib/tasks/vcr.rake +1 -1
  170. metadata +81 -21
  171. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/concern'
4
+ require 'active_support/inflector'
5
+
3
6
  module RubyLLM
4
7
  module ActiveRecord
5
8
  # Adds chat and message persistence capabilities to ActiveRecord models.
@@ -58,6 +61,8 @@ module RubyLLM
58
61
  end
59
62
 
60
63
  def acts_as_tool_call(message_class: 'Message', message_foreign_key: nil, result_foreign_key: nil)
64
+ include RubyLLM::ActiveRecord::ToolCallMethods
65
+
61
66
  @message_class = message_class.to_s
62
67
  @message_foreign_key = message_foreign_key || ActiveSupport::Inflector.foreign_key(@message_class)
63
68
  @result_foreign_key = result_foreign_key || ActiveSupport::Inflector.foreign_key(name)
@@ -158,27 +163,33 @@ module RubyLLM
158
163
  self
159
164
  end
160
165
 
161
- def on_new_message(&block)
162
- to_llm
166
+ def on_new_message(&)
167
+ to_llm.on_new_message(&)
168
+ self
169
+ end
163
170
 
164
- existing_callback = @chat.instance_variable_get(:@on)[:new_message]
171
+ def on_end_message(&)
172
+ to_llm.on_end_message(&)
173
+ self
174
+ end
165
175
 
166
- @chat.on_new_message do
167
- existing_callback&.call
168
- block&.call
169
- end
176
+ def before_message(...)
177
+ to_llm.before_message(...)
170
178
  self
171
179
  end
172
180
 
173
- def on_end_message(&block)
174
- to_llm
181
+ def after_message(...)
182
+ to_llm.after_message(...)
183
+ self
184
+ end
175
185
 
176
- existing_callback = @chat.instance_variable_get(:@on)[:end_message]
186
+ def before_tool_call(...)
187
+ to_llm.before_tool_call(...)
188
+ self
189
+ end
177
190
 
178
- @chat.on_end_message do |msg|
179
- existing_callback&.call(msg)
180
- block&.call(msg)
181
- end
191
+ def after_tool_result(...)
192
+ to_llm.after_tool_result(...)
182
193
  self
183
194
  end
184
195
 
@@ -211,7 +222,7 @@ module RubyLLM
211
222
  end
212
223
 
213
224
  def create_user_message(content, with: nil)
214
- RubyLLM.logger.warn(
225
+ RubyLLM.deprecator.warn(
215
226
  '`create_user_message` is deprecated and will be removed in RubyLLM 2.0. ' \
216
227
  'Use `add_message(role: :user, content: ...)` instead.'
217
228
  )
@@ -317,8 +328,8 @@ module RubyLLM
317
328
  def setup_persistence_callbacks
318
329
  return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
319
330
 
320
- @chat.on_new_message { persist_new_message }
321
- @chat.on_end_message { |msg| persist_message_completion(msg) }
331
+ @chat.before_message { persist_new_message }
332
+ @chat.after_message { |msg| persist_message_completion(msg) }
322
333
 
323
334
  @chat.instance_variable_set(:@_persistence_callbacks_setup, true)
324
335
  @chat
@@ -381,8 +392,8 @@ module RubyLLM
381
392
  case attachment
382
393
  when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob
383
394
  attachment
384
- when ActiveStorage::Attached::One, ActiveStorage::Attached::Many
385
- attachment.blobs
395
+ when ActiveStorage::Attachment, ActiveStorage::Attached::One, ActiveStorage::Attached::Many
396
+ active_storage_blobs(attachment)
386
397
  when Hash
387
398
  attachment.values.map { |v| prepare_for_active_storage(v) }
388
399
  else
@@ -396,16 +407,28 @@ module RubyLLM
396
407
 
397
408
  attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source)
398
409
 
399
- {
400
- io: StringIO.new(attachment.content),
401
- filename: attachment.filename,
402
- content_type: attachment.mime_type
403
- }
410
+ if attachment.active_storage?
411
+ active_storage_blobs(attachment.source)
412
+ else
413
+ {
414
+ io: StringIO.new(attachment.content),
415
+ filename: attachment.filename,
416
+ content_type: attachment.mime_type
417
+ }
418
+ end
404
419
  rescue StandardError => e
405
420
  RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
406
421
  nil
407
422
  end
408
423
 
424
+ def active_storage_blobs(attachment)
425
+ case attachment
426
+ when ActiveStorage::Blob then attachment
427
+ when ActiveStorage::Attachment, ActiveStorage::Attached::One then attachment.blob
428
+ when ActiveStorage::Attached::Many then attachment.blobs
429
+ end
430
+ end
431
+
409
432
  def build_content(message, attachments)
410
433
  return message if content_like?(message)
411
434
 
@@ -471,18 +494,91 @@ module RubyLLM
471
494
  end
472
495
 
473
496
  def extract_content
474
- return content unless respond_to?(:attachments) && attachments.attached?
497
+ text_content = if content.respond_to?(:to_plain_text)
498
+ content.to_plain_text
499
+ else
500
+ content
501
+ end
502
+
503
+ return text_content unless respond_to?(:attachments) && attachments.attached?
475
504
 
476
- RubyLLM::Content.new(content).tap do |content_obj|
505
+ RubyLLM::Content.new(text_content).tap do |content_obj|
477
506
  @_tempfiles = []
478
507
 
479
- attachments.each do |attachment|
480
- tempfile = download_attachment(attachment)
481
- content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
508
+ attachment_sources.each do |attachment, attachable|
509
+ add_attachment_to_content(content_obj, attachment, attachable)
482
510
  end
483
511
  end
484
512
  end
485
513
 
514
+ def attachment_sources
515
+ change = pending_attachment_change
516
+ return attachments.map { |attachment| [attachment, nil] } unless pending_attachment_change?(change)
517
+
518
+ change.attachments.zip(change.attachables)
519
+ end
520
+
521
+ def pending_attachment_change
522
+ attachment_changes['attachments'] if respond_to?(:attachment_changes)
523
+ end
524
+
525
+ def pending_attachment_change?(change)
526
+ change.respond_to?(:attachments) && change.respond_to?(:attachables)
527
+ end
528
+
529
+ def add_attachment_to_content(content_obj, attachment, attachable)
530
+ if pending_upload_attachable?(attachable)
531
+ add_pending_upload_attachment(content_obj, attachable)
532
+ else
533
+ tempfile = download_attachment(attachment)
534
+ content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
535
+ end
536
+ end
537
+
538
+ def pending_upload_attachable?(attachable)
539
+ return false if attachable.nil? || attachable.is_a?(String)
540
+ return false if instance_of_class?(attachable, 'ActiveStorage::Blob')
541
+
542
+ uploaded_file?(attachable) || active_storage_upload_hash?(attachable) ||
543
+ attachable.is_a?(File) || pathname?(attachable)
544
+ end
545
+
546
+ def uploaded_file?(attachable)
547
+ instance_of_class?(attachable, 'ActionDispatch::Http::UploadedFile') ||
548
+ instance_of_class?(attachable, 'Rack::Test::UploadedFile')
549
+ end
550
+
551
+ def active_storage_upload_hash?(attachable)
552
+ attachable.is_a?(Hash) && attachment_hash_io(attachable).present?
553
+ end
554
+
555
+ def pathname?(attachable)
556
+ defined?(Pathname) && attachable.is_a?(Pathname)
557
+ end
558
+
559
+ def add_pending_upload_attachment(content_obj, attachable)
560
+ if attachable.is_a?(Hash)
561
+ content_obj.add_attachment(attachment_hash_io(attachable), filename: attachment_hash_filename(attachable))
562
+ else
563
+ content_obj.add_attachment(attachable)
564
+ end
565
+ end
566
+
567
+ def attachment_hash_io(attachable)
568
+ attachable[:io] || attachable['io']
569
+ end
570
+
571
+ def attachment_hash_filename(attachable)
572
+ filename = attachable[:filename] || attachable['filename']
573
+ filename&.to_s
574
+ end
575
+
576
+ def instance_of_class?(object, class_name)
577
+ Object.const_get(class_name).then { |klass| object.is_a?(klass) }
578
+ rescue NameError
579
+ false
580
+ end
581
+
486
582
  def download_attachment(attachment)
487
583
  ext = File.extname(attachment.filename.to_s)
488
584
  basename = File.basename(attachment.filename.to_s, ext)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/concern'
4
+
3
5
  module RubyLLM
4
6
  module ActiveRecord
5
7
  # Methods mixed into chat models.
@@ -63,8 +65,8 @@ module RubyLLM
63
65
  m.context_window = model_info.context_window
64
66
  m.max_output_tokens = model_info.max_output_tokens
65
67
  m.capabilities = model_info.capabilities || []
66
- m.modalities = model_info.modalities || {}
67
- m.pricing = model_info.pricing || {}
68
+ m.modalities = model_info.modalities.to_h
69
+ m.pricing = model_info.pricing.to_h
68
70
  m.metadata = model_info.metadata || {}
69
71
  end
70
72
 
@@ -125,7 +127,7 @@ module RubyLLM
125
127
  self.assume_model_exists = assume_exists
126
128
  resolve_model_from_strings
127
129
  save!
128
- to_llm.with_model(model.model_id, provider: model.provider.to_sym, assume_exists:)
130
+ to_llm.with_model(model_association.model_id, provider: model_association.provider.to_sym, assume_exists:)
129
131
  self
130
132
  end
131
133
 
@@ -154,27 +156,33 @@ module RubyLLM
154
156
  self
155
157
  end
156
158
 
157
- def on_new_message(&block)
158
- to_llm
159
+ def on_new_message(&)
160
+ to_llm.on_new_message(&)
161
+ self
162
+ end
159
163
 
160
- existing_callback = @chat.instance_variable_get(:@on)[:new_message]
164
+ def on_end_message(&)
165
+ to_llm.on_end_message(&)
166
+ self
167
+ end
161
168
 
162
- @chat.on_new_message do
163
- existing_callback&.call
164
- block&.call
165
- end
169
+ def before_message(...)
170
+ to_llm.before_message(...)
166
171
  self
167
172
  end
168
173
 
169
- def on_end_message(&block)
170
- to_llm
174
+ def after_message(...)
175
+ to_llm.after_message(...)
176
+ self
177
+ end
171
178
 
172
- existing_callback = @chat.instance_variable_get(:@on)[:end_message]
179
+ def before_tool_call(...)
180
+ to_llm.before_tool_call(...)
181
+ self
182
+ end
173
183
 
174
- @chat.on_end_message do |msg|
175
- existing_callback&.call(msg)
176
- block&.call(msg)
177
- end
184
+ def after_tool_result(...)
185
+ to_llm.after_tool_result(...)
178
186
  self
179
187
  end
180
188
 
@@ -208,6 +216,10 @@ module RubyLLM
208
216
  message_record
209
217
  end
210
218
 
219
+ def cost
220
+ RubyLLM::Cost.aggregate(messages_association.map(&:cost))
221
+ end
222
+
211
223
  def create_user_message(content, with: nil)
212
224
  add_message(role: :user, content: build_content(content, with))
213
225
  end
@@ -258,8 +270,8 @@ module RubyLLM
258
270
  def setup_persistence_callbacks
259
271
  return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
260
272
 
261
- @chat.on_new_message { persist_new_message }
262
- @chat.on_end_message { |msg| persist_message_completion(msg) }
273
+ @chat.before_message { persist_new_message }
274
+ @chat.after_message { |msg| persist_message_completion(msg) }
263
275
 
264
276
  @chat.instance_variable_set(:@_persistence_callbacks_setup, true)
265
277
  @chat
@@ -402,8 +414,8 @@ module RubyLLM
402
414
  case attachment
403
415
  when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob
404
416
  attachment
405
- when ActiveStorage::Attached::One, ActiveStorage::Attached::Many
406
- attachment.blobs
417
+ when ActiveStorage::Attachment, ActiveStorage::Attached::One, ActiveStorage::Attached::Many
418
+ active_storage_blobs(attachment)
407
419
  when Hash
408
420
  attachment.values.map { |v| prepare_for_active_storage(v) }
409
421
  else
@@ -417,16 +429,28 @@ module RubyLLM
417
429
 
418
430
  attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source)
419
431
 
420
- {
421
- io: StringIO.new(attachment.content),
422
- filename: attachment.filename,
423
- content_type: attachment.mime_type
424
- }
432
+ if attachment.active_storage?
433
+ active_storage_blobs(attachment.source)
434
+ else
435
+ {
436
+ io: StringIO.new(attachment.content),
437
+ filename: attachment.filename,
438
+ content_type: attachment.mime_type
439
+ }
440
+ end
425
441
  rescue StandardError => e
426
442
  RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
427
443
  nil
428
444
  end
429
445
 
446
+ def active_storage_blobs(attachment)
447
+ case attachment
448
+ when ActiveStorage::Blob then attachment
449
+ when ActiveStorage::Attachment, ActiveStorage::Attached::One then attachment.blob
450
+ when ActiveStorage::Attached::Many then attachment.blobs
451
+ end
452
+ end
453
+
430
454
  def build_content(message, attachments)
431
455
  return message if content_like?(message)
432
456
 
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/concern'
4
+ require 'ruby_llm/active_record/payload_helpers'
5
+
3
6
  module RubyLLM
4
7
  module ActiveRecord
5
8
  # Methods mixed into message models.
6
9
  module MessageMethods
7
10
  extend ActiveSupport::Concern
11
+ include PayloadHelpers
8
12
 
9
13
  class_methods do
10
14
  attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key
@@ -39,6 +43,34 @@ module RubyLLM
39
43
  )
40
44
  end
41
45
 
46
+ def cost
47
+ RubyLLM::Cost.new(tokens:, model: model_association)
48
+ end
49
+
50
+ def cache_read_tokens
51
+ cached_value
52
+ end
53
+
54
+ def cache_write_tokens
55
+ cache_creation_value
56
+ end
57
+
58
+ def to_partial_path
59
+ partial_prefix = self.class.name.underscore.pluralize
60
+ role_partial = if to_llm.tool_call?
61
+ 'tool_calls'
62
+ elsif role.to_s == 'tool'
63
+ 'tool'
64
+ else
65
+ role.to_s.presence || 'assistant'
66
+ end
67
+ "#{partial_prefix}/#{role_partial}"
68
+ end
69
+
70
+ def tool_error_message
71
+ payload_error_message(content)
72
+ end
73
+
42
74
  private
43
75
 
44
76
  def thinking_text_value
@@ -82,20 +114,88 @@ module RubyLLM
82
114
  def extract_content
83
115
  return RubyLLM::Content::Raw.new(content_raw) if has_attribute?(:content_raw) && content_raw.present?
84
116
 
85
- content_value = self[:content]
117
+ content_value = content
118
+ content_value = content_value.to_plain_text if content_value.respond_to?(:to_plain_text)
86
119
 
87
120
  return content_value unless respond_to?(:attachments) && attachments.attached?
88
121
 
89
122
  RubyLLM::Content.new(content_value).tap do |content_obj|
90
123
  @_tempfiles = []
91
124
 
92
- attachments.each do |attachment|
93
- tempfile = download_attachment(attachment)
94
- content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
125
+ attachment_sources.each do |attachment, attachable|
126
+ add_attachment_to_content(content_obj, attachment, attachable)
95
127
  end
96
128
  end
97
129
  end
98
130
 
131
+ def attachment_sources
132
+ change = pending_attachment_change
133
+ return attachments.map { |attachment| [attachment, nil] } unless pending_attachment_change?(change)
134
+
135
+ change.attachments.zip(change.attachables)
136
+ end
137
+
138
+ def pending_attachment_change
139
+ attachment_changes['attachments'] if respond_to?(:attachment_changes)
140
+ end
141
+
142
+ def pending_attachment_change?(change)
143
+ change.respond_to?(:attachments) && change.respond_to?(:attachables)
144
+ end
145
+
146
+ def add_attachment_to_content(content_obj, attachment, attachable)
147
+ if pending_upload_attachable?(attachable)
148
+ add_pending_upload_attachment(content_obj, attachable)
149
+ else
150
+ tempfile = download_attachment(attachment)
151
+ content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
152
+ end
153
+ end
154
+
155
+ def pending_upload_attachable?(attachable)
156
+ return false if attachable.nil? || attachable.is_a?(String)
157
+ return false if instance_of_class?(attachable, 'ActiveStorage::Blob')
158
+
159
+ uploaded_file?(attachable) || active_storage_upload_hash?(attachable) ||
160
+ attachable.is_a?(File) || pathname?(attachable)
161
+ end
162
+
163
+ def uploaded_file?(attachable)
164
+ instance_of_class?(attachable, 'ActionDispatch::Http::UploadedFile') ||
165
+ instance_of_class?(attachable, 'Rack::Test::UploadedFile')
166
+ end
167
+
168
+ def active_storage_upload_hash?(attachable)
169
+ attachable.is_a?(Hash) && attachment_hash_io(attachable).present?
170
+ end
171
+
172
+ def pathname?(attachable)
173
+ defined?(Pathname) && attachable.is_a?(Pathname)
174
+ end
175
+
176
+ def add_pending_upload_attachment(content_obj, attachable)
177
+ if attachable.is_a?(Hash)
178
+ content_obj.add_attachment(attachment_hash_io(attachable), filename: attachment_hash_filename(attachable))
179
+ else
180
+ content_obj.add_attachment(attachable)
181
+ end
182
+ end
183
+
184
+ def attachment_hash_io(attachable)
185
+ attachable[:io] || attachable['io']
186
+ end
187
+
188
+ def attachment_hash_filename(attachable)
189
+ filename = attachable[:filename] || attachable['filename']
190
+ filename&.to_s
191
+ end
192
+
193
+ def instance_of_class?(object, class_name)
194
+ Object.const_get(class_name).then { |klass| object.is_a?(klass) }
195
+ rescue NameError
196
+ false
197
+ end
198
+
99
199
  def download_attachment(attachment)
100
200
  ext = File.extname(attachment.filename.to_s)
101
201
  basename = File.basename(attachment.filename.to_s, ext)
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/concern'
4
+ require 'active_support/core_ext/module/delegation'
5
+
3
6
  module RubyLLM
4
7
  module ActiveRecord
5
8
  # Methods mixed into model registry models.
@@ -10,15 +13,7 @@ module RubyLLM
10
13
  def refresh!
11
14
  RubyLLM.models.refresh!
12
15
 
13
- transaction do
14
- RubyLLM.models.all.each do |model_info|
15
- model = find_or_initialize_by(
16
- model_id: model_info.id,
17
- provider: model_info.provider
18
- )
19
- model.update!(from_llm_attributes(model_info))
20
- end
21
- end
16
+ save_to_database
22
17
  end
23
18
 
24
19
  def save_to_database
@@ -76,8 +71,11 @@ module RubyLLM
76
71
 
77
72
  delegate :supports?, :supports_vision?, :supports_functions?, :type,
78
73
  :input_price_per_million, :output_price_per_million,
74
+ :cache_read_input_price_per_million, :cache_write_input_price_per_million,
75
+ :cached_input_price_per_million, :cache_creation_input_price_per_million,
79
76
  :function_calling?, :structured_output?, :batch?,
80
- :reasoning?, :citations?, :streaming?, :provider_class,
77
+ :reasoning?, :citations?, :streaming?, :provider_class, :label,
78
+ :cost_for,
81
79
  to: :to_llm
82
80
  end
83
81
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/object/blank'
4
+ require 'json'
5
+
6
+ module RubyLLM
7
+ module ActiveRecord
8
+ # Shared helpers for parsing serialized payloads on ActiveRecord-backed models.
9
+ module PayloadHelpers
10
+ private
11
+
12
+ def payload_error_message(value)
13
+ payload = parse_payload(value)
14
+ return unless payload.is_a?(Hash)
15
+
16
+ payload['error'] || payload[:error]
17
+ end
18
+
19
+ def parse_payload(value)
20
+ return value if value.is_a?(Hash) || value.is_a?(Array)
21
+ return if value.blank?
22
+
23
+ JSON.parse(value)
24
+ rescue JSON::ParserError
25
+ nil
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'ruby_llm/active_record/payload_helpers'
5
+
6
+ module RubyLLM
7
+ module ActiveRecord
8
+ # Methods mixed into tool call models.
9
+ module ToolCallMethods
10
+ extend ActiveSupport::Concern
11
+ include PayloadHelpers
12
+
13
+ def tool_error_message
14
+ payload_error_message(arguments)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -121,14 +121,17 @@ module RubyLLM
121
121
  input_values, = partition_inputs(kwargs)
122
122
  record = resolved_chat_model.find(id)
123
123
  apply_configuration(record, input_values:, persist_instructions: false)
124
+
124
125
  record
125
126
  end
126
127
 
128
+ # Mutates persisted instructions on the configured chat record.
127
129
  def sync_instructions!(chat_or_id, **kwargs)
128
130
  raise ArgumentError, 'chat_model must be configured to use sync_instructions!' unless resolved_chat_model
129
131
 
130
132
  input_values, = partition_inputs(kwargs)
131
133
  record = chat_or_id.is_a?(resolved_chat_model) ? chat_or_id : resolved_chat_model.find(chat_or_id)
134
+ apply_assume_model_exists(record)
132
135
  runtime = runtime_context(chat: record, inputs: input_values)
133
136
  instructions_value = resolved_instructions_value(record, runtime, inputs: input_values)
134
137
  return record if instructions_value.nil?
@@ -227,9 +230,18 @@ module RubyLLM
227
230
  end
228
231
 
229
232
  def llm_chat_for(chat_object)
233
+ apply_assume_model_exists(chat_object)
230
234
  chat_object.respond_to?(:to_llm) ? chat_object.to_llm : chat_object
231
235
  end
232
236
 
237
+ def apply_assume_model_exists(chat_object)
238
+ return unless chat_kwargs.key?(:assume_model_exists) &&
239
+ resolved_chat_model &&
240
+ chat_object.is_a?(resolved_chat_model)
241
+
242
+ chat_object.assume_model_exists = chat_kwargs[:assume_model_exists]
243
+ end
244
+
233
245
  def evaluate(value, runtime)
234
246
  value.is_a?(Proc) ? runtime.instance_exec(&value) : value
235
247
  end
@@ -348,7 +360,8 @@ module RubyLLM
348
360
 
349
361
  def_delegators :chat, :model, :messages, :tools, :params, :headers, :schema, :ask, :say, :with_tool, :with_tools,
350
362
  :with_model, :with_temperature, :with_thinking, :with_context, :with_params, :with_headers,
351
- :with_schema, :on_new_message, :on_end_message, :on_tool_call, :on_tool_result, :each, :complete,
352
- :add_message, :reset_messages!
363
+ :with_schema, :on_new_message, :on_end_message, :on_tool_call, :on_tool_result, :before_message,
364
+ :after_message, :before_tool_call, :after_tool_result, :each, :complete, :add_message,
365
+ :reset_messages!, :cost
353
366
  end
354
367
  end