From c0246410c8ecfa609819d843402e2583a2855aaf Mon Sep 17 00:00:00 2001 From: Trevor Turk Date: Tue, 18 Nov 2025 11:14:50 -0600 Subject: [PATCH] Fix ActiveRecord dependency check for Zeitwerk eager loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap all ActiveRecord module definitions with `if defined?(ActiveRecord::Base)` to prevent Zeitwerk errors when ActiveRecord is not present. - Wrap active_record/*.rb files with ActiveRecord::Base check - Add active_record directory to Zeitwerk ignore list - Load method modules before acts_as in railtie initializer - Remove duplicate require of acts_as from lib/ruby_llm.rb Fixes Zeitwerk::NameError when eager loading without ActiveRecord. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/ruby_llm.rb | 2 +- lib/ruby_llm/active_record/acts_as.rb | 322 ++++---- lib/ruby_llm/active_record/acts_as_legacy.rb | 750 +++++++++--------- lib/ruby_llm/active_record/chat_methods.rb | 690 ++++++++-------- lib/ruby_llm/active_record/message_methods.rb | 146 ++-- lib/ruby_llm/active_record/model_methods.rb | 146 ++-- lib/ruby_llm/railtie.rb | 4 + 7 files changed, 1037 insertions(+), 1023 deletions(-) diff --git a/lib/ruby_llm.rb b/lib/ruby_llm.rb index 3e5c17a3c..1fcd2c32b 100644 --- a/lib/ruby_llm.rb +++ b/lib/ruby_llm.rb @@ -30,6 +30,7 @@ loader.ignore("#{__dir__}/tasks") loader.ignore("#{__dir__}/generators") loader.ignore("#{__dir__}/ruby_llm/railtie.rb") +loader.ignore("#{__dir__}/ruby_llm/active_record") loader.setup # A delightful Ruby interface to modern AI language models. @@ -103,5 +104,4 @@ def logger if defined?(Rails::Railtie) require 'ruby_llm/railtie' - require 'ruby_llm/active_record/acts_as' end diff --git a/lib/ruby_llm/active_record/acts_as.rb b/lib/ruby_llm/active_record/acts_as.rb index 8086957c3..2362c4c48 100644 --- a/lib/ruby_llm/active_record/acts_as.rb +++ b/lib/ruby_llm/active_record/acts_as.rb @@ -1,171 +1,173 @@ # frozen_string_literal: true -module RubyLLM - module ActiveRecord - # Adds chat and message persistence capabilities to ActiveRecord models. - module ActsAs - extend ActiveSupport::Concern - - # When ActsAs is included, ensure models are loaded from database - def self.included(base) - super - # Monkey-patch Models to use database when ActsAs is active - RubyLLM::Models.class_eval do - def self.load_models - read_from_database - rescue StandardError => e - RubyLLM.logger.debug "Failed to load models from database: #{e.message}, falling back to JSON" - read_from_json - end - - def self.read_from_database - model_class = RubyLLM.config.model_registry_class - model_class = model_class.constantize if model_class.is_a?(String) - model_class.all.map(&:to_llm) - end - - def load_from_database! - @models = self.class.read_from_database +if defined?(ActiveRecord::Base) + module RubyLLM + module ActiveRecord + # Adds chat and message persistence capabilities to ActiveRecord models. + module ActsAs + extend ActiveSupport::Concern + + # When ActsAs is included, ensure models are loaded from database + def self.included(base) + super + # Monkey-patch Models to use database when ActsAs is active + RubyLLM::Models.class_eval do + def self.load_models + read_from_database + rescue StandardError => e + RubyLLM.logger.debug "Failed to load models from database: #{e.message}, falling back to JSON" + read_from_json + end + + def self.read_from_database + model_class = RubyLLM.config.model_registry_class + model_class = model_class.constantize if model_class.is_a?(String) + model_class.all.map(&:to_llm) + end + + def load_from_database! + @models = self.class.read_from_database + end end end - end - - class_methods do # rubocop:disable Metrics/BlockLength - def acts_as_chat(messages: :messages, message_class: nil, messages_foreign_key: nil, # rubocop:disable Metrics/ParameterLists - model: :model, model_class: nil, model_foreign_key: nil) - include RubyLLM::ActiveRecord::ChatMethods - - class_attribute :messages_association_name, :model_association_name, :message_class, :model_class - - self.messages_association_name = messages - self.model_association_name = model - self.message_class = (message_class || messages.to_s.classify).to_s - self.model_class = (model_class || model.to_s.classify).to_s - - has_many messages, - -> { order(created_at: :asc) }, - class_name: self.message_class, - foreign_key: messages_foreign_key, - dependent: :destroy - - belongs_to model, - class_name: self.model_class, - foreign_key: model_foreign_key, - optional: true - - delegate :add_message, to: :to_llm - - define_method :messages_association do - send(messages_association_name) - end - - define_method :model_association do - send(model_association_name) - end - - define_method :'model_association=' do |value| - send("#{model_association_name}=", value) + + class_methods do # rubocop:disable Metrics/BlockLength + def acts_as_chat(messages: :messages, message_class: nil, messages_foreign_key: nil, # rubocop:disable Metrics/ParameterLists + model: :model, model_class: nil, model_foreign_key: nil) + include RubyLLM::ActiveRecord::ChatMethods + + class_attribute :messages_association_name, :model_association_name, :message_class, :model_class + + self.messages_association_name = messages + self.model_association_name = model + self.message_class = (message_class || messages.to_s.classify).to_s + self.model_class = (model_class || model.to_s.classify).to_s + + has_many messages, + -> { order(created_at: :asc) }, + class_name: self.message_class, + foreign_key: messages_foreign_key, + dependent: :destroy + + belongs_to model, + class_name: self.model_class, + foreign_key: model_foreign_key, + optional: true + + delegate :add_message, to: :to_llm + + define_method :messages_association do + send(messages_association_name) + end + + define_method :model_association do + send(model_association_name) + end + + define_method :'model_association=' do |value| + send("#{model_association_name}=", value) + end end - end - - def acts_as_model(chats: :chats, chat_class: nil, chats_foreign_key: nil) - include RubyLLM::ActiveRecord::ModelMethods - - class_attribute :chats_association_name, :chat_class - - self.chats_association_name = chats - self.chat_class = (chat_class || chats.to_s.classify).to_s - - validates :model_id, presence: true, uniqueness: { scope: :provider } - validates :provider, presence: true - validates :name, presence: true - - has_many chats, class_name: self.chat_class, foreign_key: chats_foreign_key - - define_method :chats_association do - send(chats_association_name) + + def acts_as_model(chats: :chats, chat_class: nil, chats_foreign_key: nil) + include RubyLLM::ActiveRecord::ModelMethods + + class_attribute :chats_association_name, :chat_class + + self.chats_association_name = chats + self.chat_class = (chat_class || chats.to_s.classify).to_s + + validates :model_id, presence: true, uniqueness: { scope: :provider } + validates :provider, presence: true + validates :name, presence: true + + has_many chats, class_name: self.chat_class, foreign_key: chats_foreign_key + + define_method :chats_association do + send(chats_association_name) + end end - end - - def acts_as_message(chat: :chat, chat_class: nil, chat_foreign_key: nil, touch_chat: false, # rubocop:disable Metrics/ParameterLists - tool_calls: :tool_calls, tool_call_class: nil, tool_calls_foreign_key: nil, - model: :model, model_class: nil, model_foreign_key: nil) - include RubyLLM::ActiveRecord::MessageMethods - - class_attribute :chat_association_name, :tool_calls_association_name, :model_association_name, - :chat_class, :tool_call_class, :model_class - - self.chat_association_name = chat - self.tool_calls_association_name = tool_calls - self.model_association_name = model - self.chat_class = (chat_class || chat.to_s.classify).to_s - self.tool_call_class = (tool_call_class || tool_calls.to_s.classify).to_s - self.model_class = (model_class || model.to_s.classify).to_s - - belongs_to chat, - class_name: self.chat_class, - foreign_key: chat_foreign_key, - touch: touch_chat - - has_many tool_calls, - class_name: self.tool_call_class, - foreign_key: tool_calls_foreign_key, - dependent: :destroy - - belongs_to :parent_tool_call, + + def acts_as_message(chat: :chat, chat_class: nil, chat_foreign_key: nil, touch_chat: false, # rubocop:disable Metrics/ParameterLists + tool_calls: :tool_calls, tool_call_class: nil, tool_calls_foreign_key: nil, + model: :model, model_class: nil, model_foreign_key: nil) + include RubyLLM::ActiveRecord::MessageMethods + + class_attribute :chat_association_name, :tool_calls_association_name, :model_association_name, + :chat_class, :tool_call_class, :model_class + + self.chat_association_name = chat + self.tool_calls_association_name = tool_calls + self.model_association_name = model + self.chat_class = (chat_class || chat.to_s.classify).to_s + self.tool_call_class = (tool_call_class || tool_calls.to_s.classify).to_s + self.model_class = (model_class || model.to_s.classify).to_s + + belongs_to chat, + class_name: self.chat_class, + foreign_key: chat_foreign_key, + touch: touch_chat + + has_many tool_calls, class_name: self.tool_call_class, - foreign_key: ActiveSupport::Inflector.foreign_key(tool_calls.to_s.singularize), - optional: true - - has_many :tool_results, - through: tool_calls, - source: :result, - class_name: name - - belongs_to model, - class_name: self.model_class, - foreign_key: model_foreign_key, - optional: true - - delegate :tool_call?, :tool_result?, to: :to_llm - - define_method :chat_association do - send(chat_association_name) - end - - define_method :tool_calls_association do - send(tool_calls_association_name) - end - - define_method :model_association do - send(model_association_name) - end - end - - def acts_as_tool_call(message: :message, message_class: nil, message_foreign_key: nil, # rubocop:disable Metrics/ParameterLists - result: :result, result_class: nil, result_foreign_key: nil) - class_attribute :message_association_name, :result_association_name, :message_class, :result_class - - self.message_association_name = message - self.result_association_name = result - self.message_class = (message_class || message.to_s.classify).to_s - self.result_class = (result_class || self.message_class).to_s - - belongs_to message, - class_name: self.message_class, - foreign_key: message_foreign_key - - has_one result, - class_name: self.result_class, - foreign_key: result_foreign_key, - dependent: :nullify - - define_method :message_association do - send(message_association_name) + foreign_key: tool_calls_foreign_key, + dependent: :destroy + + belongs_to :parent_tool_call, + class_name: self.tool_call_class, + foreign_key: ActiveSupport::Inflector.foreign_key(tool_calls.to_s.singularize), + optional: true + + has_many :tool_results, + through: tool_calls, + source: :result, + class_name: name + + belongs_to model, + class_name: self.model_class, + foreign_key: model_foreign_key, + optional: true + + delegate :tool_call?, :tool_result?, to: :to_llm + + define_method :chat_association do + send(chat_association_name) + end + + define_method :tool_calls_association do + send(tool_calls_association_name) + end + + define_method :model_association do + send(model_association_name) + end end - - define_method :result_association do - send(result_association_name) + + def acts_as_tool_call(message: :message, message_class: nil, message_foreign_key: nil, # rubocop:disable Metrics/ParameterLists + result: :result, result_class: nil, result_foreign_key: nil) + class_attribute :message_association_name, :result_association_name, :message_class, :result_class + + self.message_association_name = message + self.result_association_name = result + self.message_class = (message_class || message.to_s.classify).to_s + self.result_class = (result_class || self.message_class).to_s + + belongs_to message, + class_name: self.message_class, + foreign_key: message_foreign_key + + has_one result, + class_name: self.result_class, + foreign_key: result_foreign_key, + dependent: :nullify + + define_method :message_association do + send(message_association_name) + end + + define_method :result_association do + send(result_association_name) + end end end end diff --git a/lib/ruby_llm/active_record/acts_as_legacy.rb b/lib/ruby_llm/active_record/acts_as_legacy.rb index 97679c126..1e2ca1d34 100644 --- a/lib/ruby_llm/active_record/acts_as_legacy.rb +++ b/lib/ruby_llm/active_record/acts_as_legacy.rb @@ -1,397 +1,399 @@ # frozen_string_literal: true -module RubyLLM - module ActiveRecord - # Adds chat and message persistence capabilities to ActiveRecord models. - module ActsAsLegacy - extend ActiveSupport::Concern - - class_methods do # rubocop:disable Metrics/BlockLength - def acts_as_chat(message_class: 'Message', tool_call_class: 'ToolCall') - include ChatLegacyMethods - - @message_class = message_class.to_s - @tool_call_class = tool_call_class.to_s - - has_many :messages, - -> { order(created_at: :asc) }, - class_name: @message_class, - inverse_of: :chat, - dependent: :destroy - - delegate :add_message, to: :to_llm - end - - def acts_as_message(chat_class: 'Chat', - chat_foreign_key: nil, - tool_call_class: 'ToolCall', - tool_call_foreign_key: nil, - touch_chat: false) - include MessageLegacyMethods - - @chat_class = chat_class.to_s - @chat_foreign_key = chat_foreign_key || ActiveSupport::Inflector.foreign_key(@chat_class) - - @tool_call_class = tool_call_class.to_s - @tool_call_foreign_key = tool_call_foreign_key || ActiveSupport::Inflector.foreign_key(@tool_call_class) - - belongs_to :chat, - class_name: @chat_class, - foreign_key: @chat_foreign_key, - inverse_of: :messages, - touch: touch_chat - - has_many :tool_calls, - class_name: @tool_call_class, - dependent: :destroy - - belongs_to :parent_tool_call, - class_name: @tool_call_class, - foreign_key: @tool_call_foreign_key, - optional: true, - inverse_of: :result - - has_many :tool_results, - through: :tool_calls, - source: :result, - class_name: @message_class - - delegate :tool_call?, :tool_result?, to: :to_llm - end - - def acts_as_tool_call(message_class: 'Message', message_foreign_key: nil, result_foreign_key: nil) - @message_class = message_class.to_s - @message_foreign_key = message_foreign_key || ActiveSupport::Inflector.foreign_key(@message_class) - @result_foreign_key = result_foreign_key || ActiveSupport::Inflector.foreign_key(name) - - belongs_to :message, +if defined?(ActiveRecord::Base) + module RubyLLM + module ActiveRecord + # Adds chat and message persistence capabilities to ActiveRecord models. + module ActsAsLegacy + extend ActiveSupport::Concern + + class_methods do # rubocop:disable Metrics/BlockLength + def acts_as_chat(message_class: 'Message', tool_call_class: 'ToolCall') + include ChatLegacyMethods + + @message_class = message_class.to_s + @tool_call_class = tool_call_class.to_s + + has_many :messages, + -> { order(created_at: :asc) }, class_name: @message_class, - foreign_key: @message_foreign_key, - inverse_of: :tool_calls - - has_one :result, - class_name: @message_class, - foreign_key: @result_foreign_key, - inverse_of: :parent_tool_call, - dependent: :nullify + inverse_of: :chat, + dependent: :destroy + + delegate :add_message, to: :to_llm + end + + def acts_as_message(chat_class: 'Chat', + chat_foreign_key: nil, + tool_call_class: 'ToolCall', + tool_call_foreign_key: nil, + touch_chat: false) + include MessageLegacyMethods + + @chat_class = chat_class.to_s + @chat_foreign_key = chat_foreign_key || ActiveSupport::Inflector.foreign_key(@chat_class) + + @tool_call_class = tool_call_class.to_s + @tool_call_foreign_key = tool_call_foreign_key || ActiveSupport::Inflector.foreign_key(@tool_call_class) + + belongs_to :chat, + class_name: @chat_class, + foreign_key: @chat_foreign_key, + inverse_of: :messages, + touch: touch_chat + + has_many :tool_calls, + class_name: @tool_call_class, + dependent: :destroy + + belongs_to :parent_tool_call, + class_name: @tool_call_class, + foreign_key: @tool_call_foreign_key, + optional: true, + inverse_of: :result + + has_many :tool_results, + through: :tool_calls, + source: :result, + class_name: @message_class + + delegate :tool_call?, :tool_result?, to: :to_llm + end + + def acts_as_tool_call(message_class: 'Message', message_foreign_key: nil, result_foreign_key: nil) + @message_class = message_class.to_s + @message_foreign_key = message_foreign_key || ActiveSupport::Inflector.foreign_key(@message_class) + @result_foreign_key = result_foreign_key || ActiveSupport::Inflector.foreign_key(name) + + belongs_to :message, + class_name: @message_class, + foreign_key: @message_foreign_key, + inverse_of: :tool_calls + + has_one :result, + class_name: @message_class, + foreign_key: @result_foreign_key, + inverse_of: :parent_tool_call, + dependent: :nullify + end end end - end - - # Methods mixed into chat models. - module ChatLegacyMethods - extend ActiveSupport::Concern - - class_methods do - attr_reader :tool_call_class - end - - def to_llm(context: nil) - # model_id is a string that RubyLLM can resolve - @chat ||= if context - context.chat(model: model_id) - else - RubyLLM.chat(model: model_id) - end - @chat.reset_messages! - - messages.each do |msg| - @chat.add_message(msg.to_llm) + + # Methods mixed into chat models. + module ChatLegacyMethods + extend ActiveSupport::Concern + + class_methods do + attr_reader :tool_call_class end - - setup_persistence_callbacks - end - - def with_instructions(instructions, replace: false) - transaction do - messages.where(role: :system).destroy_all if replace - messages.create!(role: :system, content: instructions) + + def to_llm(context: nil) + # model_id is a string that RubyLLM can resolve + @chat ||= if context + context.chat(model: model_id) + else + RubyLLM.chat(model: model_id) + end + @chat.reset_messages! + + messages.each do |msg| + @chat.add_message(msg.to_llm) + end + + setup_persistence_callbacks end - to_llm.with_instructions(instructions) - self - end - - def with_tool(...) - to_llm.with_tool(...) - self - end - - def with_tools(...) - to_llm.with_tools(...) - self - end - - def with_model(...) - update(model_id: to_llm.with_model(...).model.id) - self - end - - def with_temperature(...) - to_llm.with_temperature(...) - self - end - - def with_context(context) - to_llm(context: context) - self - end - - def with_params(...) - to_llm.with_params(...) - self - end - - def with_headers(...) - to_llm.with_headers(...) - self - end - - def with_schema(...) - to_llm.with_schema(...) - self - end - - def on_new_message(&block) - to_llm - - existing_callback = @chat.instance_variable_get(:@on)[:new_message] - - @chat.on_new_message do - existing_callback&.call - block&.call + + def with_instructions(instructions, replace: false) + transaction do + messages.where(role: :system).destroy_all if replace + messages.create!(role: :system, content: instructions) + end + to_llm.with_instructions(instructions) + self end - self - end - - def on_end_message(&block) - to_llm - - existing_callback = @chat.instance_variable_get(:@on)[:end_message] - - @chat.on_end_message do |msg| - existing_callback&.call(msg) - block&.call(msg) + + def with_tool(...) + to_llm.with_tool(...) + self end - self - end - - def on_tool_call(...) - to_llm.on_tool_call(...) - self - end - - def on_tool_result(...) - to_llm.on_tool_result(...) - self - end - - def create_user_message(content, with: nil) - message_record = messages.create!(role: :user, content: content) - persist_content(message_record, with) if with.present? - message_record - end - - def ask(message, with: nil, &) - create_user_message(message, with:) - complete(&) - end - - alias say ask - - def complete(...) - to_llm.complete(...) - rescue RubyLLM::Error => e - cleanup_failed_messages if @message&.persisted? && @message.content.blank? - cleanup_orphaned_tool_results - raise e - end - - private - - def cleanup_failed_messages - RubyLLM.logger.warn "RubyLLM: API call failed, destroying message: #{@message.id}" - @message.destroy - end - - def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity - messages.reload - last = messages.order(:id).last - - return unless last&.tool_call? || last&.tool_result? - - if last.tool_call? - last.destroy - elsif last.tool_result? - tool_call_message = last.parent_tool_call.message - expected_results = tool_call_message.tool_calls.pluck(:id) - actual_results = tool_call_message.tool_results.pluck(:tool_call_id) - - if expected_results.sort != actual_results.sort - tool_call_message.tool_results.each(&:destroy) - tool_call_message.destroy + + def with_tools(...) + to_llm.with_tools(...) + self + end + + def with_model(...) + update(model_id: to_llm.with_model(...).model.id) + self + end + + def with_temperature(...) + to_llm.with_temperature(...) + self + end + + def with_context(context) + to_llm(context: context) + self + end + + def with_params(...) + to_llm.with_params(...) + self + end + + def with_headers(...) + to_llm.with_headers(...) + self + end + + def with_schema(...) + to_llm.with_schema(...) + self + end + + def on_new_message(&block) + to_llm + + existing_callback = @chat.instance_variable_get(:@on)[:new_message] + + @chat.on_new_message do + existing_callback&.call + block&.call end + self end - end - - def setup_persistence_callbacks - return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup) - - @chat.on_new_message { persist_new_message } - @chat.on_end_message { |msg| persist_message_completion(msg) } - - @chat.instance_variable_set(:@_persistence_callbacks_setup, true) - @chat - end - - def persist_new_message - @message = messages.create!(role: :assistant, content: '') - end - - def persist_message_completion(message) # rubocop:disable Metrics/PerceivedComplexity - return unless message - - tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id - - transaction do - content = message.content - attachments_to_persist = nil - - if content.is_a?(RubyLLM::Content) - attachments_to_persist = content.attachments if content.attachments.any? - content = content.text - elsif content.is_a?(Hash) || content.is_a?(Array) - content = content.to_json + + def on_end_message(&block) + to_llm + + existing_callback = @chat.instance_variable_get(:@on)[:end_message] + + @chat.on_end_message do |msg| + existing_callback&.call(msg) + block&.call(msg) end - - @message.update!( - role: message.role, - content: content, - model_id: message.model_id, - input_tokens: message.input_tokens, - output_tokens: message.output_tokens - ) - @message.write_attribute(@message.class.tool_call_foreign_key, tool_call_id) if tool_call_id - @message.save! - - persist_content(@message, attachments_to_persist) if attachments_to_persist - persist_tool_calls(message.tool_calls) if message.tool_calls.present? + self end - end - - def persist_tool_calls(tool_calls) - tool_calls.each_value do |tool_call| - attributes = tool_call.to_h - attributes[:tool_call_id] = attributes.delete(:id) - @message.tool_calls.create!(**attributes) + + def on_tool_call(...) + to_llm.on_tool_call(...) + self end - end - - def find_tool_call_id(tool_call_id) - self.class.tool_call_class.constantize.find_by(tool_call_id: tool_call_id)&.id - end - - def persist_content(message_record, attachments) - return unless message_record.respond_to?(:attachments) - - attachables = prepare_for_active_storage(attachments) - message_record.attachments.attach(attachables) if attachables.any? - end - - def prepare_for_active_storage(attachments) - Utils.to_safe_array(attachments).filter_map do |attachment| - case attachment - when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob - attachment - when ActiveStorage::Attached::One, ActiveStorage::Attached::Many - attachment.blobs - when Hash - attachment.values.map { |v| prepare_for_active_storage(v) } - else - convert_to_active_storage_format(attachment) + + def on_tool_result(...) + to_llm.on_tool_result(...) + self + end + + def create_user_message(content, with: nil) + message_record = messages.create!(role: :user, content: content) + persist_content(message_record, with) if with.present? + message_record + end + + def ask(message, with: nil, &) + create_user_message(message, with:) + complete(&) + end + + alias say ask + + def complete(...) + to_llm.complete(...) + rescue RubyLLM::Error => e + cleanup_failed_messages if @message&.persisted? && @message.content.blank? + cleanup_orphaned_tool_results + raise e + end + + private + + def cleanup_failed_messages + RubyLLM.logger.warn "RubyLLM: API call failed, destroying message: #{@message.id}" + @message.destroy + end + + def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity + messages.reload + last = messages.order(:id).last + + return unless last&.tool_call? || last&.tool_result? + + if last.tool_call? + last.destroy + elsif last.tool_result? + tool_call_message = last.parent_tool_call.message + expected_results = tool_call_message.tool_calls.pluck(:id) + actual_results = tool_call_message.tool_results.pluck(:tool_call_id) + + if expected_results.sort != actual_results.sort + tool_call_message.tool_results.each(&:destroy) + tool_call_message.destroy + end end - end.flatten.compact - end - - def convert_to_active_storage_format(source) - return if source.blank? - - attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source) - - { - io: StringIO.new(attachment.content), - filename: attachment.filename, - content_type: attachment.mime_type - } - rescue StandardError => e - RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}" - nil - end - end - - # Methods mixed into message models. - module MessageLegacyMethods - extend ActiveSupport::Concern - - class_methods do - attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key - end - - def to_llm - RubyLLM::Message.new( - role: role.to_sym, - content: extract_content, - tool_calls: extract_tool_calls, - tool_call_id: extract_tool_call_id, - input_tokens: input_tokens, - output_tokens: output_tokens, - model_id: model_id - ) - end - - private - - def extract_tool_calls - tool_calls.to_h do |tool_call| - [ - tool_call.tool_call_id, - RubyLLM::ToolCall.new( - id: tool_call.tool_call_id, - name: tool_call.name, - arguments: tool_call.arguments + end + + def setup_persistence_callbacks + return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup) + + @chat.on_new_message { persist_new_message } + @chat.on_end_message { |msg| persist_message_completion(msg) } + + @chat.instance_variable_set(:@_persistence_callbacks_setup, true) + @chat + end + + def persist_new_message + @message = messages.create!(role: :assistant, content: '') + end + + def persist_message_completion(message) # rubocop:disable Metrics/PerceivedComplexity + return unless message + + tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id + + transaction do + content = message.content + attachments_to_persist = nil + + if content.is_a?(RubyLLM::Content) + attachments_to_persist = content.attachments if content.attachments.any? + content = content.text + elsif content.is_a?(Hash) || content.is_a?(Array) + content = content.to_json + end + + @message.update!( + role: message.role, + content: content, + model_id: message.model_id, + input_tokens: message.input_tokens, + output_tokens: message.output_tokens ) - ] + @message.write_attribute(@message.class.tool_call_foreign_key, tool_call_id) if tool_call_id + @message.save! + + persist_content(@message, attachments_to_persist) if attachments_to_persist + persist_tool_calls(message.tool_calls) if message.tool_calls.present? + end end - end - - def extract_tool_call_id - parent_tool_call&.tool_call_id - end - - def extract_content - return content unless respond_to?(:attachments) && attachments.attached? - - RubyLLM::Content.new(content).tap do |content_obj| - @_tempfiles = [] - - attachments.each do |attachment| - tempfile = download_attachment(attachment) - content_obj.add_attachment(tempfile, filename: attachment.filename.to_s) + + def persist_tool_calls(tool_calls) + tool_calls.each_value do |tool_call| + attributes = tool_call.to_h + attributes[:tool_call_id] = attributes.delete(:id) + @message.tool_calls.create!(**attributes) end end + + def find_tool_call_id(tool_call_id) + self.class.tool_call_class.constantize.find_by(tool_call_id: tool_call_id)&.id + end + + def persist_content(message_record, attachments) + return unless message_record.respond_to?(:attachments) + + attachables = prepare_for_active_storage(attachments) + message_record.attachments.attach(attachables) if attachables.any? + end + + def prepare_for_active_storage(attachments) + Utils.to_safe_array(attachments).filter_map do |attachment| + case attachment + when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob + attachment + when ActiveStorage::Attached::One, ActiveStorage::Attached::Many + attachment.blobs + when Hash + attachment.values.map { |v| prepare_for_active_storage(v) } + else + convert_to_active_storage_format(attachment) + end + end.flatten.compact + end + + def convert_to_active_storage_format(source) + return if source.blank? + + attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source) + + { + io: StringIO.new(attachment.content), + filename: attachment.filename, + content_type: attachment.mime_type + } + rescue StandardError => e + RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}" + nil + end end - - def download_attachment(attachment) - ext = File.extname(attachment.filename.to_s) - basename = File.basename(attachment.filename.to_s, ext) - tempfile = Tempfile.new([basename, ext]) - tempfile.binmode - - attachment.download { |chunk| tempfile.write(chunk) } - - tempfile.flush - tempfile.rewind - @_tempfiles << tempfile - tempfile + + # Methods mixed into message models. + module MessageLegacyMethods + extend ActiveSupport::Concern + + class_methods do + attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key + end + + def to_llm + RubyLLM::Message.new( + role: role.to_sym, + content: extract_content, + tool_calls: extract_tool_calls, + tool_call_id: extract_tool_call_id, + input_tokens: input_tokens, + output_tokens: output_tokens, + model_id: model_id + ) + end + + private + + def extract_tool_calls + tool_calls.to_h do |tool_call| + [ + tool_call.tool_call_id, + RubyLLM::ToolCall.new( + id: tool_call.tool_call_id, + name: tool_call.name, + arguments: tool_call.arguments + ) + ] + end + end + + def extract_tool_call_id + parent_tool_call&.tool_call_id + end + + def extract_content + return content unless respond_to?(:attachments) && attachments.attached? + + RubyLLM::Content.new(content).tap do |content_obj| + @_tempfiles = [] + + attachments.each do |attachment| + tempfile = download_attachment(attachment) + content_obj.add_attachment(tempfile, filename: attachment.filename.to_s) + end + end + end + + def download_attachment(attachment) + ext = File.extname(attachment.filename.to_s) + basename = File.basename(attachment.filename.to_s, ext) + tempfile = Tempfile.new([basename, ext]) + tempfile.binmode + + attachment.download { |chunk| tempfile.write(chunk) } + + tempfile.flush + tempfile.rewind + @_tempfiles << tempfile + tempfile + end end end end diff --git a/lib/ruby_llm/active_record/chat_methods.rb b/lib/ruby_llm/active_record/chat_methods.rb index 41930548c..2f640fbf1 100644 --- a/lib/ruby_llm/active_record/chat_methods.rb +++ b/lib/ruby_llm/active_record/chat_methods.rb @@ -1,363 +1,365 @@ # frozen_string_literal: true -module RubyLLM - module ActiveRecord - # Methods mixed into chat models. - module ChatMethods - extend ActiveSupport::Concern - - included do - before_save :resolve_model_from_strings - end - - attr_accessor :assume_model_exists, :context - - def model=(value) - @model_string = value if value.is_a?(String) - return if value.is_a?(String) - - if self.class.model_association_name == :model - super - else - self.model_association = value +if defined?(ActiveRecord::Base) + module RubyLLM + module ActiveRecord + # Methods mixed into chat models. + module ChatMethods + extend ActiveSupport::Concern + + included do + before_save :resolve_model_from_strings end - end - - def model_id=(value) - @model_string = value - end - - def model_id - model_association&.model_id - end - - def provider=(value) - @provider_string = value - end - - def provider - model_association&.provider - end - - private - - def resolve_model_from_strings # rubocop:disable Metrics/PerceivedComplexity - config = context&.config || RubyLLM.config - @model_string ||= config.default_model unless model_association - return unless @model_string - - model_info, _provider = Models.resolve( - @model_string, - provider: @provider_string, - assume_exists: assume_model_exists || false, - config: config - ) - - model_class = self.class.model_class.constantize - model_record = model_class.find_or_create_by!( - model_id: model_info.id, - provider: model_info.provider - ) do |m| - m.name = model_info.name || model_info.id - m.family = model_info.family - m.context_window = model_info.context_window - m.max_output_tokens = model_info.max_output_tokens - m.capabilities = model_info.capabilities || [] - m.modalities = model_info.modalities || {} - m.pricing = model_info.pricing || {} - m.metadata = model_info.metadata || {} + + attr_accessor :assume_model_exists, :context + + def model=(value) + @model_string = value if value.is_a?(String) + return if value.is_a?(String) + + if self.class.model_association_name == :model + super + else + self.model_association = value + end end - - self.model_association = model_record - @model_string = nil - @provider_string = nil - end - - public - - def to_llm - model_record = model_association - @chat ||= (context || RubyLLM).chat( - model: model_record.model_id, - provider: model_record.provider.to_sym - ) - @chat.reset_messages! - - messages_association.each do |msg| - @chat.add_message(msg.to_llm) + + def model_id=(value) + @model_string = value end - - setup_persistence_callbacks - end - - def with_instructions(instructions, replace: false) - transaction do - messages_association.where(role: :system).destroy_all if replace - messages_association.create!(role: :system, content: instructions) + + def model_id + model_association&.model_id end - to_llm.with_instructions(instructions) - self - end - - def with_tool(...) - to_llm.with_tool(...) - self - end - - def with_tools(...) - to_llm.with_tools(...) - self - end - - def with_model(model_name, provider: nil, assume_exists: false) - self.model = model_name - self.provider = provider if provider - self.assume_model_exists = assume_exists - resolve_model_from_strings - save! - to_llm.with_model(model.model_id, provider: model.provider.to_sym, assume_exists:) - self - end - - def with_temperature(...) - to_llm.with_temperature(...) - self - end - - def with_params(...) - to_llm.with_params(...) - self - end - - def with_headers(...) - to_llm.with_headers(...) - self - end - - def with_schema(...) - to_llm.with_schema(...) - self - end - - def on_new_message(&block) - to_llm - - existing_callback = @chat.instance_variable_get(:@on)[:new_message] - - @chat.on_new_message do - existing_callback&.call - block&.call + + def provider=(value) + @provider_string = value end - self - end - - def on_end_message(&block) - to_llm - - existing_callback = @chat.instance_variable_get(:@on)[:end_message] - - @chat.on_end_message do |msg| - existing_callback&.call(msg) - block&.call(msg) + + def provider + model_association&.provider end - self - end - - def on_tool_call(...) - to_llm.on_tool_call(...) - self - end - - def on_tool_result(...) - to_llm.on_tool_result(...) - self - end - - def create_user_message(content, with: nil) - content_text, attachments, content_raw = prepare_content_for_storage(content) - - message_record = messages_association.build(role: :user) - message_record.content = content_text - message_record.content_raw = content_raw if message_record.respond_to?(:content_raw=) - message_record.save! - - persist_content(message_record, with) if with.present? - persist_content(message_record, attachments) if attachments.present? - - message_record - end - - def ask(message, with: nil, &) - create_user_message(message, with:) - complete(&) - end - - alias say ask - - def complete(...) - to_llm.complete(...) - rescue RubyLLM::Error => e - cleanup_failed_messages if @message&.persisted? && @message.content.blank? - cleanup_orphaned_tool_results - raise e - end - - private - - def cleanup_failed_messages - RubyLLM.logger.warn "RubyLLM: API call failed, destroying message: #{@message.id}" - @message.destroy - end - - def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity - messages_association.reload - last = messages_association.order(:id).last - - return unless last&.tool_call? || last&.tool_result? - - if last.tool_call? - last.destroy - elsif last.tool_result? - tool_call_message = last.parent_tool_call.message - expected_results = tool_call_message.tool_calls.pluck(:id) - actual_results = tool_call_message.tool_results.pluck(:tool_call_id) - - if expected_results.sort != actual_results.sort - tool_call_message.tool_results.each(&:destroy) - tool_call_message.destroy + + private + + def resolve_model_from_strings # rubocop:disable Metrics/PerceivedComplexity + config = context&.config || RubyLLM.config + @model_string ||= config.default_model unless model_association + return unless @model_string + + model_info, _provider = Models.resolve( + @model_string, + provider: @provider_string, + assume_exists: assume_model_exists || false, + config: config + ) + + model_class = self.class.model_class.constantize + model_record = model_class.find_or_create_by!( + model_id: model_info.id, + provider: model_info.provider + ) do |m| + m.name = model_info.name || model_info.id + m.family = model_info.family + m.context_window = model_info.context_window + m.max_output_tokens = model_info.max_output_tokens + m.capabilities = model_info.capabilities || [] + m.modalities = model_info.modalities || {} + m.pricing = model_info.pricing || {} + m.metadata = model_info.metadata || {} end + + self.model_association = model_record + @model_string = nil + @provider_string = nil end - end - - def setup_persistence_callbacks - return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup) - - @chat.on_new_message { persist_new_message } - @chat.on_end_message { |msg| persist_message_completion(msg) } - - @chat.instance_variable_set(:@_persistence_callbacks_setup, true) - @chat - end - - def persist_new_message - @message = messages_association.create!(role: :assistant, content: '') - end - - # rubocop:disable Metrics/PerceivedComplexity - def persist_message_completion(message) - return unless message - - tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id - - transaction do - content_text, attachments_to_persist, content_raw = prepare_content_for_storage(message.content) - - attrs = { - role: message.role, - content: content_text, - input_tokens: message.input_tokens, - output_tokens: message.output_tokens - } - attrs[:cached_tokens] = message.cached_tokens if @message.has_attribute?(:cached_tokens) - if @message.has_attribute?(:cache_creation_tokens) - attrs[:cache_creation_tokens] = message.cache_creation_tokens + + public + + def to_llm + model_record = model_association + @chat ||= (context || RubyLLM).chat( + model: model_record.model_id, + provider: model_record.provider.to_sym + ) + @chat.reset_messages! + + messages_association.each do |msg| + @chat.add_message(msg.to_llm) end - - # Add model association dynamically - attrs[self.class.model_association_name] = model_association - - if tool_call_id - parent_tool_call_assoc = @message.class.reflect_on_association(:parent_tool_call) - attrs[parent_tool_call_assoc.foreign_key] = tool_call_id + + setup_persistence_callbacks + end + + def with_instructions(instructions, replace: false) + transaction do + messages_association.where(role: :system).destroy_all if replace + messages_association.create!(role: :system, content: instructions) end - - @message.assign_attributes(attrs) - @message.content_raw = content_raw if @message.respond_to?(:content_raw=) - @message.save! - - persist_content(@message, attachments_to_persist) if attachments_to_persist - persist_tool_calls(message.tool_calls) if message.tool_calls.present? + to_llm.with_instructions(instructions) + self end - end - # rubocop:enable Metrics/PerceivedComplexity - - def persist_tool_calls(tool_calls) - tool_calls.each_value do |tool_call| - attributes = tool_call.to_h - attributes[:tool_call_id] = attributes.delete(:id) - @message.tool_calls_association.create!(**attributes) + + def with_tool(...) + to_llm.with_tool(...) + self end - end - - def find_tool_call_id(tool_call_id) - messages = messages_association - message_class = messages.klass - tool_calls_assoc = message_class.tool_calls_association_name - tool_call_table_name = message_class.reflect_on_association(tool_calls_assoc).table_name - - message_with_tool_call = messages.joins(tool_calls_assoc) - .find_by(tool_call_table_name => { tool_call_id: tool_call_id }) - return nil unless message_with_tool_call - - tool_call = message_with_tool_call.tool_calls_association.find_by(tool_call_id: tool_call_id) - tool_call&.id - end - - def persist_content(message_record, attachments) - return unless message_record.respond_to?(:attachments) - - attachables = prepare_for_active_storage(attachments) - message_record.attachments.attach(attachables) if attachables.any? - end - - def prepare_for_active_storage(attachments) - Utils.to_safe_array(attachments).filter_map do |attachment| - case attachment - when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob - attachment - when ActiveStorage::Attached::One, ActiveStorage::Attached::Many - attachment.blobs - when Hash - attachment.values.map { |v| prepare_for_active_storage(v) } - else - convert_to_active_storage_format(attachment) + + def with_tools(...) + to_llm.with_tools(...) + self + end + + def with_model(model_name, provider: nil, assume_exists: false) + self.model = model_name + self.provider = provider if provider + self.assume_model_exists = assume_exists + resolve_model_from_strings + save! + to_llm.with_model(model.model_id, provider: model.provider.to_sym, assume_exists:) + self + end + + def with_temperature(...) + to_llm.with_temperature(...) + self + end + + def with_params(...) + to_llm.with_params(...) + self + end + + def with_headers(...) + to_llm.with_headers(...) + self + end + + def with_schema(...) + to_llm.with_schema(...) + self + end + + def on_new_message(&block) + to_llm + + existing_callback = @chat.instance_variable_get(:@on)[:new_message] + + @chat.on_new_message do + existing_callback&.call + block&.call end - end.flatten.compact - end - - def convert_to_active_storage_format(source) - return if source.blank? - - attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source) - - { - io: StringIO.new(attachment.content), - filename: attachment.filename, - content_type: attachment.mime_type - } - rescue StandardError => e - RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}" - nil - end - - def prepare_content_for_storage(content) - attachments = nil - content_raw = nil - content_text = content - - case content - when RubyLLM::Content::Raw - content_raw = content.value - content_text = nil - when RubyLLM::Content - attachments = content.attachments if content.attachments.any? - content_text = content.text - when Hash, Array - content_raw = content - content_text = nil + self + end + + def on_end_message(&block) + to_llm + + existing_callback = @chat.instance_variable_get(:@on)[:end_message] + + @chat.on_end_message do |msg| + existing_callback&.call(msg) + block&.call(msg) + end + self + end + + def on_tool_call(...) + to_llm.on_tool_call(...) + self + end + + def on_tool_result(...) + to_llm.on_tool_result(...) + self + end + + def create_user_message(content, with: nil) + content_text, attachments, content_raw = prepare_content_for_storage(content) + + message_record = messages_association.build(role: :user) + message_record.content = content_text + message_record.content_raw = content_raw if message_record.respond_to?(:content_raw=) + message_record.save! + + persist_content(message_record, with) if with.present? + persist_content(message_record, attachments) if attachments.present? + + message_record + end + + def ask(message, with: nil, &) + create_user_message(message, with:) + complete(&) + end + + alias say ask + + def complete(...) + to_llm.complete(...) + rescue RubyLLM::Error => e + cleanup_failed_messages if @message&.persisted? && @message.content.blank? + cleanup_orphaned_tool_results + raise e + end + + private + + def cleanup_failed_messages + RubyLLM.logger.warn "RubyLLM: API call failed, destroying message: #{@message.id}" + @message.destroy + end + + def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity + messages_association.reload + last = messages_association.order(:id).last + + return unless last&.tool_call? || last&.tool_result? + + if last.tool_call? + last.destroy + elsif last.tool_result? + tool_call_message = last.parent_tool_call.message + expected_results = tool_call_message.tool_calls.pluck(:id) + actual_results = tool_call_message.tool_results.pluck(:tool_call_id) + + if expected_results.sort != actual_results.sort + tool_call_message.tool_results.each(&:destroy) + tool_call_message.destroy + end + end + end + + def setup_persistence_callbacks + return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup) + + @chat.on_new_message { persist_new_message } + @chat.on_end_message { |msg| persist_message_completion(msg) } + + @chat.instance_variable_set(:@_persistence_callbacks_setup, true) + @chat + end + + def persist_new_message + @message = messages_association.create!(role: :assistant, content: '') + end + + # rubocop:disable Metrics/PerceivedComplexity + def persist_message_completion(message) + return unless message + + tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id + + transaction do + content_text, attachments_to_persist, content_raw = prepare_content_for_storage(message.content) + + attrs = { + role: message.role, + content: content_text, + input_tokens: message.input_tokens, + output_tokens: message.output_tokens + } + attrs[:cached_tokens] = message.cached_tokens if @message.has_attribute?(:cached_tokens) + if @message.has_attribute?(:cache_creation_tokens) + attrs[:cache_creation_tokens] = message.cache_creation_tokens + end + + # Add model association dynamically + attrs[self.class.model_association_name] = model_association + + if tool_call_id + parent_tool_call_assoc = @message.class.reflect_on_association(:parent_tool_call) + attrs[parent_tool_call_assoc.foreign_key] = tool_call_id + end + + @message.assign_attributes(attrs) + @message.content_raw = content_raw if @message.respond_to?(:content_raw=) + @message.save! + + persist_content(@message, attachments_to_persist) if attachments_to_persist + persist_tool_calls(message.tool_calls) if message.tool_calls.present? + end + end + # rubocop:enable Metrics/PerceivedComplexity + + def persist_tool_calls(tool_calls) + tool_calls.each_value do |tool_call| + attributes = tool_call.to_h + attributes[:tool_call_id] = attributes.delete(:id) + @message.tool_calls_association.create!(**attributes) + end + end + + def find_tool_call_id(tool_call_id) + messages = messages_association + message_class = messages.klass + tool_calls_assoc = message_class.tool_calls_association_name + tool_call_table_name = message_class.reflect_on_association(tool_calls_assoc).table_name + + message_with_tool_call = messages.joins(tool_calls_assoc) + .find_by(tool_call_table_name => { tool_call_id: tool_call_id }) + return nil unless message_with_tool_call + + tool_call = message_with_tool_call.tool_calls_association.find_by(tool_call_id: tool_call_id) + tool_call&.id + end + + def persist_content(message_record, attachments) + return unless message_record.respond_to?(:attachments) + + attachables = prepare_for_active_storage(attachments) + message_record.attachments.attach(attachables) if attachables.any? + end + + def prepare_for_active_storage(attachments) + Utils.to_safe_array(attachments).filter_map do |attachment| + case attachment + when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob + attachment + when ActiveStorage::Attached::One, ActiveStorage::Attached::Many + attachment.blobs + when Hash + attachment.values.map { |v| prepare_for_active_storage(v) } + else + convert_to_active_storage_format(attachment) + end + end.flatten.compact + end + + def convert_to_active_storage_format(source) + return if source.blank? + + attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source) + + { + io: StringIO.new(attachment.content), + filename: attachment.filename, + content_type: attachment.mime_type + } + rescue StandardError => e + RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}" + nil + end + + def prepare_content_for_storage(content) + attachments = nil + content_raw = nil + content_text = content + + case content + when RubyLLM::Content::Raw + content_raw = content.value + content_text = nil + when RubyLLM::Content + attachments = content.attachments if content.attachments.any? + content_text = content.text + when Hash, Array + content_raw = content + content_text = nil + end + + [content_text, attachments, content_raw] end - - [content_text, attachments, content_raw] end end end diff --git a/lib/ruby_llm/active_record/message_methods.rb b/lib/ruby_llm/active_record/message_methods.rb index 334352409..89b09d335 100644 --- a/lib/ruby_llm/active_record/message_methods.rb +++ b/lib/ruby_llm/active_record/message_methods.rb @@ -1,80 +1,82 @@ # frozen_string_literal: true -module RubyLLM - module ActiveRecord - # Methods mixed into message models. - module MessageMethods - extend ActiveSupport::Concern - - class_methods do - attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key - end - - def to_llm - cached = has_attribute?(:cached_tokens) ? self[:cached_tokens] : nil - cache_creation = has_attribute?(:cache_creation_tokens) ? self[:cache_creation_tokens] : nil - - RubyLLM::Message.new( - role: role.to_sym, - content: extract_content, - tool_calls: extract_tool_calls, - tool_call_id: extract_tool_call_id, - input_tokens: input_tokens, - output_tokens: output_tokens, - cached_tokens: cached, - cache_creation_tokens: cache_creation, - model_id: model_association&.model_id - ) - end - - private - - def extract_tool_calls - tool_calls_association.to_h do |tool_call| - [ - tool_call.tool_call_id, - RubyLLM::ToolCall.new( - id: tool_call.tool_call_id, - name: tool_call.name, - arguments: tool_call.arguments - ) - ] +if defined?(ActiveRecord::Base) + module RubyLLM + module ActiveRecord + # Methods mixed into message models. + module MessageMethods + extend ActiveSupport::Concern + + class_methods do + attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key end - end - - def extract_tool_call_id - parent_tool_call&.tool_call_id - end - - def extract_content - return RubyLLM::Content::Raw.new(content_raw) if has_attribute?(:content_raw) && content_raw.present? - - content_value = self[:content] - - return content_value unless respond_to?(:attachments) && attachments.attached? - - RubyLLM::Content.new(content_value).tap do |content_obj| - @_tempfiles = [] - - attachments.each do |attachment| - tempfile = download_attachment(attachment) - content_obj.add_attachment(tempfile, filename: attachment.filename.to_s) + + def to_llm + cached = has_attribute?(:cached_tokens) ? self[:cached_tokens] : nil + cache_creation = has_attribute?(:cache_creation_tokens) ? self[:cache_creation_tokens] : nil + + RubyLLM::Message.new( + role: role.to_sym, + content: extract_content, + tool_calls: extract_tool_calls, + tool_call_id: extract_tool_call_id, + input_tokens: input_tokens, + output_tokens: output_tokens, + cached_tokens: cached, + cache_creation_tokens: cache_creation, + model_id: model_association&.model_id + ) + end + + private + + def extract_tool_calls + tool_calls_association.to_h do |tool_call| + [ + tool_call.tool_call_id, + RubyLLM::ToolCall.new( + id: tool_call.tool_call_id, + name: tool_call.name, + arguments: tool_call.arguments + ) + ] end end - end - - def download_attachment(attachment) - ext = File.extname(attachment.filename.to_s) - basename = File.basename(attachment.filename.to_s, ext) - tempfile = Tempfile.new([basename, ext]) - tempfile.binmode - - attachment.download { |chunk| tempfile.write(chunk) } - - tempfile.flush - tempfile.rewind - @_tempfiles << tempfile - tempfile + + def extract_tool_call_id + parent_tool_call&.tool_call_id + end + + def extract_content + return RubyLLM::Content::Raw.new(content_raw) if has_attribute?(:content_raw) && content_raw.present? + + content_value = self[:content] + + return content_value unless respond_to?(:attachments) && attachments.attached? + + RubyLLM::Content.new(content_value).tap do |content_obj| + @_tempfiles = [] + + attachments.each do |attachment| + tempfile = download_attachment(attachment) + content_obj.add_attachment(tempfile, filename: attachment.filename.to_s) + end + end + end + + def download_attachment(attachment) + ext = File.extname(attachment.filename.to_s) + basename = File.basename(attachment.filename.to_s, ext) + tempfile = Tempfile.new([basename, ext]) + tempfile.binmode + + attachment.download { |chunk| tempfile.write(chunk) } + + tempfile.flush + tempfile.rewind + @_tempfiles << tempfile + tempfile + end end end end diff --git a/lib/ruby_llm/active_record/model_methods.rb b/lib/ruby_llm/active_record/model_methods.rb index 8313b475a..de40c0c08 100644 --- a/lib/ruby_llm/active_record/model_methods.rb +++ b/lib/ruby_llm/active_record/model_methods.rb @@ -1,84 +1,86 @@ # frozen_string_literal: true -module RubyLLM - module ActiveRecord - # Methods mixed into model registry models. - module ModelMethods - extend ActiveSupport::Concern - - class_methods do # rubocop:disable Metrics/BlockLength - def refresh! - RubyLLM.models.refresh! - - transaction do - RubyLLM.models.all.each do |model_info| - model = find_or_initialize_by( - model_id: model_info.id, - provider: model_info.provider - ) - model.update!(from_llm_attributes(model_info)) +if defined?(ActiveRecord::Base) + module RubyLLM + module ActiveRecord + # Methods mixed into model registry models. + module ModelMethods + extend ActiveSupport::Concern + + class_methods do # rubocop:disable Metrics/BlockLength + def refresh! + RubyLLM.models.refresh! + + transaction do + RubyLLM.models.all.each do |model_info| + model = find_or_initialize_by( + model_id: model_info.id, + provider: model_info.provider + ) + model.update!(from_llm_attributes(model_info)) + end end end - end - - def save_to_database - transaction do - RubyLLM.models.all.each do |model_info| - model = find_or_initialize_by( - model_id: model_info.id, - provider: model_info.provider - ) - model.update!(from_llm_attributes(model_info)) + + def save_to_database + transaction do + RubyLLM.models.all.each do |model_info| + model = find_or_initialize_by( + model_id: model_info.id, + provider: model_info.provider + ) + model.update!(from_llm_attributes(model_info)) + end end end + + def from_llm(model_info) + new(from_llm_attributes(model_info)) + end + + private + + def from_llm_attributes(model_info) + { + model_id: model_info.id, + name: model_info.name, + provider: model_info.provider, + family: model_info.family, + model_created_at: model_info.created_at, + context_window: model_info.context_window, + max_output_tokens: model_info.max_output_tokens, + knowledge_cutoff: model_info.knowledge_cutoff, + modalities: model_info.modalities.to_h, + capabilities: model_info.capabilities, + pricing: model_info.pricing.to_h, + metadata: model_info.metadata + } + end end - - def from_llm(model_info) - new(from_llm_attributes(model_info)) - end - - private - - def from_llm_attributes(model_info) - { - model_id: model_info.id, - name: model_info.name, - provider: model_info.provider, - family: model_info.family, - model_created_at: model_info.created_at, - context_window: model_info.context_window, - max_output_tokens: model_info.max_output_tokens, - knowledge_cutoff: model_info.knowledge_cutoff, - modalities: model_info.modalities.to_h, - capabilities: model_info.capabilities, - pricing: model_info.pricing.to_h, - metadata: model_info.metadata - } + + def to_llm + RubyLLM::Model::Info.new( + id: model_id, + name: name, + provider: provider, + family: family, + created_at: model_created_at, + context_window: context_window, + max_output_tokens: max_output_tokens, + knowledge_cutoff: knowledge_cutoff, + modalities: modalities&.deep_symbolize_keys || {}, + capabilities: capabilities, + pricing: pricing&.deep_symbolize_keys || {}, + metadata: metadata&.deep_symbolize_keys || {} + ) end + + delegate :supports?, :supports_vision?, :supports_functions?, :type, + :input_price_per_million, :output_price_per_million, + :function_calling?, :structured_output?, :batch?, + :reasoning?, :citations?, :streaming?, :provider_class, + to: :to_llm end - - def to_llm - RubyLLM::Model::Info.new( - id: model_id, - name: name, - provider: provider, - family: family, - created_at: model_created_at, - context_window: context_window, - max_output_tokens: max_output_tokens, - knowledge_cutoff: knowledge_cutoff, - modalities: modalities&.deep_symbolize_keys || {}, - capabilities: capabilities, - pricing: pricing&.deep_symbolize_keys || {}, - metadata: metadata&.deep_symbolize_keys || {} - ) - end - - delegate :supports?, :supports_vision?, :supports_functions?, :type, - :input_price_per_million, :output_price_per_million, - :function_calling?, :structured_output?, :batch?, - :reasoning?, :citations?, :streaming?, :provider_class, - to: :to_llm end end end diff --git a/lib/ruby_llm/railtie.rb b/lib/ruby_llm/railtie.rb index 97a368b06..a83a2741a 100644 --- a/lib/ruby_llm/railtie.rb +++ b/lib/ruby_llm/railtie.rb @@ -12,6 +12,10 @@ class Railtie < Rails::Railtie initializer 'ruby_llm.active_record' do ActiveSupport.on_load :active_record do + require 'ruby_llm/active_record/chat_methods' + require 'ruby_llm/active_record/message_methods' + require 'ruby_llm/active_record/model_methods' + if RubyLLM.config.use_new_acts_as require 'ruby_llm/active_record/acts_as' ::ActiveRecord::Base.include RubyLLM::ActiveRecord::ActsAs