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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -7
  3. data/lib/generators/ruby_llm/generator_helpers.rb +8 -0
  4. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +1 -1
  5. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +1 -1
  6. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +3 -3
  7. data/lib/ruby_llm/active_record/acts_as.rb +4 -26
  8. data/lib/ruby_llm/active_record/acts_as_legacy.rb +123 -29
  9. data/lib/ruby_llm/active_record/chat_methods.rb +41 -24
  10. data/lib/ruby_llm/active_record/message_methods.rb +87 -4
  11. data/lib/ruby_llm/active_record/model_methods.rb +7 -9
  12. data/lib/ruby_llm/active_record/payload_helpers.rb +3 -0
  13. data/lib/ruby_llm/active_record/tool_call_methods.rb +3 -0
  14. data/lib/ruby_llm/agent.rb +4 -2
  15. data/lib/ruby_llm/aliases.json +108 -75
  16. data/lib/ruby_llm/aliases.rb +3 -0
  17. data/lib/ruby_llm/attachment.rb +41 -40
  18. data/lib/ruby_llm/chat.rb +229 -59
  19. data/lib/ruby_llm/configuration.rb +14 -1
  20. data/lib/ruby_llm/connection.rb +36 -7
  21. data/lib/ruby_llm/content.rb +15 -1
  22. data/lib/ruby_llm/cost.rb +224 -0
  23. data/lib/ruby_llm/deprecator.rb +24 -0
  24. data/lib/ruby_llm/embedding.rb +31 -1
  25. data/lib/ruby_llm/error.rb +11 -75
  26. data/lib/ruby_llm/error_middleware.rb +81 -0
  27. data/lib/ruby_llm/image.rb +39 -4
  28. data/lib/ruby_llm/instrumentation.rb +36 -0
  29. data/lib/ruby_llm/message.rb +20 -0
  30. data/lib/ruby_llm/mime_type.rb +25 -0
  31. data/lib/ruby_llm/model/info.rb +53 -2
  32. data/lib/ruby_llm/model/pricing.rb +19 -9
  33. data/lib/ruby_llm/model/pricing_category.rb +13 -2
  34. data/lib/ruby_llm/model/pricing_tier.rb +20 -9
  35. data/lib/ruby_llm/model_registry.rb +39 -0
  36. data/lib/ruby_llm/models.json +17817 -13942
  37. data/lib/ruby_llm/models.rb +97 -31
  38. data/lib/ruby_llm/models_schema.json +3 -0
  39. data/lib/ruby_llm/provider.rb +20 -4
  40. data/lib/ruby_llm/providers/anthropic/chat.rb +49 -15
  41. data/lib/ruby_llm/providers/anthropic/models.rb +2 -0
  42. data/lib/ruby_llm/providers/anthropic/streaming.rb +2 -0
  43. data/lib/ruby_llm/providers/anthropic/tools.rb +32 -3
  44. data/lib/ruby_llm/providers/azure/media.rb +1 -1
  45. data/lib/ruby_llm/providers/bedrock/auth.rb +1 -0
  46. data/lib/ruby_llm/providers/bedrock/chat.rb +26 -13
  47. data/lib/ruby_llm/providers/bedrock/media.rb +21 -3
  48. data/lib/ruby_llm/providers/bedrock/models.rb +1 -1
  49. data/lib/ruby_llm/providers/bedrock/streaming.rb +10 -1
  50. data/lib/ruby_llm/providers/bedrock.rb +2 -2
  51. data/lib/ruby_llm/providers/deepseek/capabilities.rb +43 -0
  52. data/lib/ruby_llm/providers/deepseek/chat.rb +9 -0
  53. data/lib/ruby_llm/providers/gemini/chat.rb +10 -4
  54. data/lib/ruby_llm/providers/gemini/images.rb +2 -2
  55. data/lib/ruby_llm/providers/gemini/media.rb +16 -9
  56. data/lib/ruby_llm/providers/gemini/streaming.rb +6 -1
  57. data/lib/ruby_llm/providers/gemini/tools.rb +5 -1
  58. data/lib/ruby_llm/providers/gpustack/chat.rb +8 -1
  59. data/lib/ruby_llm/providers/gpustack/models.rb +2 -0
  60. data/lib/ruby_llm/providers/mistral/capabilities.rb +7 -2
  61. data/lib/ruby_llm/providers/mistral/chat.rb +56 -5
  62. data/lib/ruby_llm/providers/mistral/media.rb +55 -0
  63. data/lib/ruby_llm/providers/mistral/models.rb +2 -0
  64. data/lib/ruby_llm/providers/mistral.rb +2 -2
  65. data/lib/ruby_llm/providers/ollama/chat.rb +8 -1
  66. data/lib/ruby_llm/providers/openai/capabilities.rb +82 -12
  67. data/lib/ruby_llm/providers/openai/chat.rb +61 -7
  68. data/lib/ruby_llm/providers/openai/images.rb +58 -6
  69. data/lib/ruby_llm/providers/openai/media.rb +40 -16
  70. data/lib/ruby_llm/providers/openai/streaming.rb +7 -6
  71. data/lib/ruby_llm/providers/openai/tools.rb +2 -0
  72. data/lib/ruby_llm/providers/openai/transcription.rb +1 -0
  73. data/lib/ruby_llm/providers/openrouter/chat.rb +36 -8
  74. data/lib/ruby_llm/providers/openrouter/images.rb +2 -2
  75. data/lib/ruby_llm/providers/openrouter/models.rb +1 -1
  76. data/lib/ruby_llm/providers/openrouter/streaming.rb +5 -6
  77. data/lib/ruby_llm/providers/perplexity/chat.rb +11 -0
  78. data/lib/ruby_llm/providers/perplexity/media.rb +62 -0
  79. data/lib/ruby_llm/providers/perplexity.rb +2 -2
  80. data/lib/ruby_llm/providers/vertexai.rb +5 -1
  81. data/lib/ruby_llm/providers/xai/chat.rb +9 -0
  82. data/lib/ruby_llm/providers/xai/models.rb +15 -27
  83. data/lib/ruby_llm/providers/xai.rb +2 -2
  84. data/lib/ruby_llm/railtie.rb +11 -1
  85. data/lib/ruby_llm/stream_accumulator.rb +45 -30
  86. data/lib/ruby_llm/streaming.rb +4 -0
  87. data/lib/ruby_llm/tokens.rb +8 -0
  88. data/lib/ruby_llm/tool.rb +24 -7
  89. data/lib/ruby_llm/tool_concurrency.rb +105 -0
  90. data/lib/ruby_llm/transcription.rb +2 -1
  91. data/lib/ruby_llm/utils.rb +39 -0
  92. data/lib/ruby_llm/version.rb +1 -1
  93. data/lib/ruby_llm.rb +11 -6
  94. data/lib/tasks/models.rake +45 -16
  95. data/lib/tasks/release.rake +50 -23
  96. metadata +35 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e29291ded69046aa32b77f524a148e14ce1b1bfbcaddea1da99b5cee66f7b13
4
- data.tar.gz: '08a7aaa292f898f0e25f69658f19289799eb5e520feabf8a736bb3737d05fb9d'
3
+ metadata.gz: ba1ac6556eb9321cc5c4840eb10c4f2253a35991298d3a4f9cf6dd85035ba069
4
+ data.tar.gz: f87c2f1e7c1898c31ae732db0bae3a96f8b9c9082d8f9abd9a728b2b9d295426
5
5
  SHA512:
6
- metadata.gz: e9d4a4c8f284fc94669217c0979da4f6e6eccc95e0e47a8429b64c35c447ade3d994f278431c1593bc1fef804545e2bc0f5529775bc209b72dd728eeb4d99016
7
- data.tar.gz: c6ed2232f289a940ad3b9ee29279005528bf74a5921b911fda19e8e2cc6ecb939405e46db76a7e1e83f580da69ed368fa197355b05efada524a537419ac37381
6
+ metadata.gz: f75855b2dc79679febdf4edb67130f5ee60d7445bd1a34d7f29c69f3cd363711b881ffe2b51eac8fa4f2a74b297e364e53263e12674333055c8daf702d528a3a
7
+ data.tar.gz: aceac99dd86c4e4db028411d1d58cd2602351f3ba0b3cb7fc2334d979459a97294f2617e10770d851b57f70aa26b49f9995dd239d1f52cff43814d1293419a75
data/README.md CHANGED
@@ -5,9 +5,10 @@
5
5
  <img src="/docs/assets/images/logotype.svg" alt="RubyLLM" height="120" width="250">
6
6
  </picture>
7
7
 
8
- <strong>One *beautiful* Ruby API for GPT, Claude, Gemini, and more.</strong>
8
+ <strong>A single, beautiful Ruby framework for all major AI providers. Easily build chatbots, AI agents, RAG applications, content generators, and every AI workflow you can think of.
9
+ </strong>
9
10
 
10
- Battle tested at [<picture><source media="(prefers-color-scheme: dark)" srcset="https://chatwithwork.com/logotype-dark.svg"><img src="https://chatwithwork.com/logotype.svg" alt="Chat with Work" height="30" align="absmiddle"></picture>](https://chatwithwork.com) *Your AI coworker*
11
+ Battle tested at [<picture><source media="(prefers-color-scheme: dark)" srcset="https://chatwithwork.com/logotype-dark.svg"><img src="https://chatwithwork.com/logotype.svg" alt="Chat with Work" height="30" align="absmiddle"></picture>](https://chatwithwork.com) - *Fully private work AI*
11
12
 
12
13
  [![Gem Version](https://badge.fury.io/rb/ruby_llm.svg)](https://badge.fury.io/rb/ruby_llm)
13
14
  [![Ruby Style Guide](https://img.shields.io/badge/code_style-rubocop-brightgreen.svg)](https://github.com/rubocop/rubocop)
@@ -24,7 +25,7 @@ Battle tested at [<picture><source media="(prefers-color-scheme: dark)" srcset="
24
25
 
25
26
  Build chatbots, AI agents, RAG applications. Works with OpenAI, xAI, Anthropic, Google, AWS, local models, and any OpenAI-compatible API.
26
27
 
27
- ## From zero to AI chat app in under two minutes
28
+ ## Build a working Ruby AI chat in two minutes
28
29
 
29
30
  https://github.com/user-attachments/assets/65422091-9338-47da-a303-92b918bd1345
30
31
 
@@ -32,7 +33,7 @@ https://github.com/user-attachments/assets/65422091-9338-47da-a303-92b918bd1345
32
33
 
33
34
  Every AI provider ships their own bloated client. Different APIs. Different response formats. Different conventions. It's exhausting.
34
35
 
35
- RubyLLM gives you one beautiful API for all of them. Same interface whether you're using GPT, Claude, or your local Ollama. Just three dependencies: Faraday, Zeitwerk, and Marcel. That's it.
36
+ RubyLLM gives you one beautiful framework for all of them. Same interface whether you're using GPT, Claude, or your local Ollama. Just three dependencies: Faraday, Zeitwerk, and Marcel. That's it.
36
37
 
37
38
  ## Show me the code
38
39
 
@@ -86,9 +87,7 @@ RubyLLM.moderate "Check if this text is safe"
86
87
  ```ruby
87
88
  # Let AI use your code
88
89
  class Weather < RubyLLM::Tool
89
- description "Get current weather"
90
- param :latitude
91
- param :longitude
90
+ desc "Get current weather"
92
91
 
93
92
  def execute(latitude:, longitude:)
94
93
  url = "https://api.open-meteo.com/v1/forecast?latitude=#{latitude}&longitude=#{longitude}&current=temperature_2m,wind_speed_10m"
@@ -87,6 +87,7 @@ module RubyLLM
87
87
 
88
88
  add_association_params(params, :message, message_table_name, message_model_name,
89
89
  owner_table: tool_call_table_name, owner_model_name: tool_call_model_name)
90
+ add_result_foreign_key_param(params)
90
91
 
91
92
  "acts_as_tool_call#{" #{params.join(', ')}" if params.any?}"
92
93
  end
@@ -178,6 +179,13 @@ module RubyLLM
178
179
  "#{owner_model_name.demodulize.underscore}_id"
179
180
  end
180
181
 
182
+ def add_result_foreign_key_param(params)
183
+ foreign_key = "#{tool_call_table_name.singularize}_id"
184
+ default_foreign_key = "#{tool_call_model_name.demodulize.underscore}_id"
185
+
186
+ params << "result_foreign_key: :#{foreign_key}" if foreign_key != default_foreign_key
187
+ end
188
+
181
189
  # Convert namespaced model names to proper table names
182
190
  # e.g., "Assistant::Chat" -> "assistant_chats" (not "assistant/chats")
183
191
  def table_name_for(model_name)
@@ -2,7 +2,7 @@ RubyLLM.configure do |config|
2
2
  config.openai_api_key = ENV.fetch("OPENAI_API_KEY", Rails.application.credentials.dig(:openai_api_key))
3
3
  # config.default_model = "gpt-5-nano"
4
4
 
5
- # Use the new association-based acts_as API (recommended)
5
+ # Use the association-based acts_as API (recommended)
6
6
  config.use_new_acts_as = true
7
7
  <% if model_model_name != 'Model' -%>
8
8
 
@@ -1,5 +1,5 @@
1
1
  class <%= class_name %>Tool < RubyLLM::Tool
2
- description "TODO: describe what this tool does"
2
+ desc "TODO: describe what this tool does"
3
3
 
4
4
  def execute
5
5
  # TODO: return something to the LLM.
@@ -6,7 +6,7 @@ require_relative '../generator_helpers'
6
6
 
7
7
  module RubyLLM
8
8
  module Generators
9
- # Generator to upgrade existing RubyLLM apps to v1.7 with new Rails-like API
9
+ # Generator to upgrade existing RubyLLM apps to v1.7 with association-based Rails API
10
10
  class UpgradeToV17Generator < Rails::Generators::Base
11
11
  include Rails::Generators::Migration
12
12
  include RubyLLM::Generators::GeneratorHelpers
@@ -24,7 +24,7 @@ module RubyLLM
24
24
 
25
25
  argument :model_mappings, type: :array, default: [], banner: 'chat:ChatName message:MessageName ...'
26
26
 
27
- desc 'Upgrades existing RubyLLM apps to v1.7 with new Rails-like API\n' \
27
+ desc 'Upgrades existing RubyLLM apps to v1.7 with association-based Rails API\n' \
28
28
  'Usage: bin/rails g ruby_llm:upgrade_to_v1_7 [chat:ChatName] [message:MessageName] ...'
29
29
 
30
30
  def self.next_migration_number(dirname)
@@ -78,7 +78,7 @@ module RubyLLM
78
78
  return if initializer_content.include?('config.use_new_acts_as')
79
79
 
80
80
  inject_into_file initializer_path, before: /^end/ do
81
- lines = ["\n # Enable the new Rails-like API", ' config.use_new_acts_as = true']
81
+ lines = ["\n # Enable the association-based Rails API", ' config.use_new_acts_as = true']
82
82
  lines << " config.model_registry_class = \"#{model_model_name}\"" if model_model_name != 'Model'
83
83
  lines << "\n"
84
84
  lines.join("\n")
@@ -1,39 +1,17 @@
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.
6
9
  module ActsAs
7
10
  extend ActiveSupport::Concern
8
11
 
9
- # When ActsAs is included, ensure models are loaded from database
10
12
  def self.included(base)
11
13
  super
12
- # Monkey-patch Models to use database when ActsAs is active
13
- RubyLLM::Models.class_eval do
14
- def self.load_models
15
- database_models = read_from_database
16
- return database_models if database_models.any?
17
-
18
- RubyLLM.logger.debug { 'Model registry is empty in database, falling back to JSON registry' }
19
- read_from_json
20
- rescue StandardError => e
21
- RubyLLM.logger.debug { "Failed to load models from database: #{e.message}, falling back to JSON" }
22
- read_from_json
23
- end
24
-
25
- def self.read_from_database
26
- model_class = RubyLLM.config.model_registry_class
27
- model_class = model_class.constantize if model_class.is_a?(String)
28
- return [] unless model_class.table_exists?
29
-
30
- model_class.all.map(&:to_llm)
31
- end
32
-
33
- def load_from_database!
34
- @models = self.class.read_from_database
35
- end
36
- end
14
+ RubyLLM.config.model_registry_source ||= RubyLLM::ModelRegistry::ActiveRecordSource.new
37
15
  end
38
16
 
39
17
  class_methods do # rubocop:disable Metrics/BlockLength
@@ -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.
@@ -160,27 +163,33 @@ module RubyLLM
160
163
  self
161
164
  end
162
165
 
163
- def on_new_message(&block)
164
- to_llm
166
+ def on_new_message(&)
167
+ to_llm.on_new_message(&)
168
+ self
169
+ end
165
170
 
166
- existing_callback = @chat.instance_variable_get(:@on)[:new_message]
171
+ def on_end_message(&)
172
+ to_llm.on_end_message(&)
173
+ self
174
+ end
167
175
 
168
- @chat.on_new_message do
169
- existing_callback&.call
170
- block&.call
171
- end
176
+ def before_message(...)
177
+ to_llm.before_message(...)
172
178
  self
173
179
  end
174
180
 
175
- def on_end_message(&block)
176
- to_llm
181
+ def after_message(...)
182
+ to_llm.after_message(...)
183
+ self
184
+ end
177
185
 
178
- existing_callback = @chat.instance_variable_get(:@on)[:end_message]
186
+ def before_tool_call(...)
187
+ to_llm.before_tool_call(...)
188
+ self
189
+ end
179
190
 
180
- @chat.on_end_message do |msg|
181
- existing_callback&.call(msg)
182
- block&.call(msg)
183
- end
191
+ def after_tool_result(...)
192
+ to_llm.after_tool_result(...)
184
193
  self
185
194
  end
186
195
 
@@ -213,7 +222,7 @@ module RubyLLM
213
222
  end
214
223
 
215
224
  def create_user_message(content, with: nil)
216
- RubyLLM.logger.warn(
225
+ RubyLLM.deprecator.warn(
217
226
  '`create_user_message` is deprecated and will be removed in RubyLLM 2.0. ' \
218
227
  'Use `add_message(role: :user, content: ...)` instead.'
219
228
  )
@@ -319,8 +328,8 @@ module RubyLLM
319
328
  def setup_persistence_callbacks
320
329
  return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
321
330
 
322
- @chat.on_new_message { persist_new_message }
323
- @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) }
324
333
 
325
334
  @chat.instance_variable_set(:@_persistence_callbacks_setup, true)
326
335
  @chat
@@ -383,8 +392,8 @@ module RubyLLM
383
392
  case attachment
384
393
  when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob
385
394
  attachment
386
- when ActiveStorage::Attached::One, ActiveStorage::Attached::Many
387
- attachment.blobs
395
+ when ActiveStorage::Attachment, ActiveStorage::Attached::One, ActiveStorage::Attached::Many
396
+ active_storage_blobs(attachment)
388
397
  when Hash
389
398
  attachment.values.map { |v| prepare_for_active_storage(v) }
390
399
  else
@@ -398,16 +407,28 @@ module RubyLLM
398
407
 
399
408
  attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source)
400
409
 
401
- {
402
- io: StringIO.new(attachment.content),
403
- filename: attachment.filename,
404
- content_type: attachment.mime_type
405
- }
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
406
419
  rescue StandardError => e
407
420
  RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
408
421
  nil
409
422
  end
410
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
+
411
432
  def build_content(message, attachments)
412
433
  return message if content_like?(message)
413
434
 
@@ -473,18 +494,91 @@ module RubyLLM
473
494
  end
474
495
 
475
496
  def extract_content
476
- 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?
477
504
 
478
- RubyLLM::Content.new(content).tap do |content_obj|
505
+ RubyLLM::Content.new(text_content).tap do |content_obj|
479
506
  @_tempfiles = []
480
507
 
481
- attachments.each do |attachment|
482
- tempfile = download_attachment(attachment)
483
- 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)
484
510
  end
485
511
  end
486
512
  end
487
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
+
488
582
  def download_attachment(attachment)
489
583
  ext = File.extname(attachment.filename.to_s)
490
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
 
@@ -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
@@ -418,10 +430,7 @@ module RubyLLM
418
430
  attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source)
419
431
 
420
432
  if attachment.active_storage?
421
- case attachment.source
422
- when ActiveStorage::Blob then attachment.source
423
- when ActiveStorage::Attached::One, ActiveStorage::Attached::Many then attachment.source.blobs
424
- end
433
+ active_storage_blobs(attachment.source)
425
434
  else
426
435
  {
427
436
  io: StringIO.new(attachment.content),
@@ -434,6 +443,14 @@ module RubyLLM
434
443
  nil
435
444
  end
436
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
+
437
454
  def build_content(message, attachments)
438
455
  return message if content_like?(message)
439
456
 
@@ -1,5 +1,8 @@
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.
@@ -40,6 +43,18 @@ module RubyLLM
40
43
  )
41
44
  end
42
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
+
43
58
  def to_partial_path
44
59
  partial_prefix = self.class.name.underscore.pluralize
45
60
  role_partial = if to_llm.tool_call?
@@ -99,20 +114,88 @@ module RubyLLM
99
114
  def extract_content
100
115
  return RubyLLM::Content::Raw.new(content_raw) if has_attribute?(:content_raw) && content_raw.present?
101
116
 
102
- content_value = self[:content]
117
+ content_value = content
118
+ content_value = content_value.to_plain_text if content_value.respond_to?(:to_plain_text)
103
119
 
104
120
  return content_value unless respond_to?(:attachments) && attachments.attached?
105
121
 
106
122
  RubyLLM::Content.new(content_value).tap do |content_obj|
107
123
  @_tempfiles = []
108
124
 
109
- attachments.each do |attachment|
110
- tempfile = download_attachment(attachment)
111
- 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)
112
127
  end
113
128
  end
114
129
  end
115
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
+
116
199
  def download_attachment(attachment)
117
200
  ext = File.extname(attachment.filename.to_s)
118
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
77
  :reasoning?, :citations?, :streaming?, :provider_class, :label,
78
+ :cost_for,
81
79
  to: :to_llm
82
80
  end
83
81
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/core_ext/object/blank'
4
+ require 'json'
5
+
3
6
  module RubyLLM
4
7
  module ActiveRecord
5
8
  # Shared helpers for parsing serialized payloads on ActiveRecord-backed models.