dify_llm 1.6.4

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 (129) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +157 -0
  4. data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +3 -0
  5. data/lib/generators/ruby_llm/install/templates/create_chats_legacy_migration.rb.tt +8 -0
  6. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +8 -0
  7. data/lib/generators/ruby_llm/install/templates/create_messages_legacy_migration.rb.tt +16 -0
  8. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +16 -0
  9. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +43 -0
  10. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +9 -0
  12. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -0
  13. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
  14. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +3 -0
  15. data/lib/generators/ruby_llm/install_generator.rb +184 -0
  16. data/lib/generators/ruby_llm/migrate_model_fields/templates/migration.rb.tt +142 -0
  17. data/lib/generators/ruby_llm/migrate_model_fields_generator.rb +84 -0
  18. data/lib/ruby_llm/active_record/acts_as.rb +137 -0
  19. data/lib/ruby_llm/active_record/acts_as_legacy.rb +398 -0
  20. data/lib/ruby_llm/active_record/chat_methods.rb +315 -0
  21. data/lib/ruby_llm/active_record/message_methods.rb +72 -0
  22. data/lib/ruby_llm/active_record/model_methods.rb +84 -0
  23. data/lib/ruby_llm/aliases.json +274 -0
  24. data/lib/ruby_llm/aliases.rb +38 -0
  25. data/lib/ruby_llm/attachment.rb +191 -0
  26. data/lib/ruby_llm/chat.rb +212 -0
  27. data/lib/ruby_llm/chunk.rb +6 -0
  28. data/lib/ruby_llm/configuration.rb +69 -0
  29. data/lib/ruby_llm/connection.rb +137 -0
  30. data/lib/ruby_llm/content.rb +50 -0
  31. data/lib/ruby_llm/context.rb +29 -0
  32. data/lib/ruby_llm/embedding.rb +29 -0
  33. data/lib/ruby_llm/error.rb +76 -0
  34. data/lib/ruby_llm/image.rb +49 -0
  35. data/lib/ruby_llm/message.rb +76 -0
  36. data/lib/ruby_llm/mime_type.rb +67 -0
  37. data/lib/ruby_llm/model/info.rb +103 -0
  38. data/lib/ruby_llm/model/modalities.rb +22 -0
  39. data/lib/ruby_llm/model/pricing.rb +48 -0
  40. data/lib/ruby_llm/model/pricing_category.rb +46 -0
  41. data/lib/ruby_llm/model/pricing_tier.rb +33 -0
  42. data/lib/ruby_llm/model.rb +7 -0
  43. data/lib/ruby_llm/models.json +31418 -0
  44. data/lib/ruby_llm/models.rb +235 -0
  45. data/lib/ruby_llm/models_schema.json +168 -0
  46. data/lib/ruby_llm/provider.rb +215 -0
  47. data/lib/ruby_llm/providers/anthropic/capabilities.rb +134 -0
  48. data/lib/ruby_llm/providers/anthropic/chat.rb +106 -0
  49. data/lib/ruby_llm/providers/anthropic/embeddings.rb +20 -0
  50. data/lib/ruby_llm/providers/anthropic/media.rb +91 -0
  51. data/lib/ruby_llm/providers/anthropic/models.rb +48 -0
  52. data/lib/ruby_llm/providers/anthropic/streaming.rb +43 -0
  53. data/lib/ruby_llm/providers/anthropic/tools.rb +107 -0
  54. data/lib/ruby_llm/providers/anthropic.rb +36 -0
  55. data/lib/ruby_llm/providers/bedrock/capabilities.rb +167 -0
  56. data/lib/ruby_llm/providers/bedrock/chat.rb +63 -0
  57. data/lib/ruby_llm/providers/bedrock/media.rb +60 -0
  58. data/lib/ruby_llm/providers/bedrock/models.rb +98 -0
  59. data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
  60. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +51 -0
  61. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +56 -0
  62. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +67 -0
  63. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +78 -0
  64. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +78 -0
  65. data/lib/ruby_llm/providers/bedrock/streaming.rb +18 -0
  66. data/lib/ruby_llm/providers/bedrock.rb +82 -0
  67. data/lib/ruby_llm/providers/deepseek/capabilities.rb +130 -0
  68. data/lib/ruby_llm/providers/deepseek/chat.rb +16 -0
  69. data/lib/ruby_llm/providers/deepseek.rb +30 -0
  70. data/lib/ruby_llm/providers/dify/capabilities.rb +16 -0
  71. data/lib/ruby_llm/providers/dify/chat.rb +59 -0
  72. data/lib/ruby_llm/providers/dify/media.rb +37 -0
  73. data/lib/ruby_llm/providers/dify/streaming.rb +28 -0
  74. data/lib/ruby_llm/providers/dify.rb +48 -0
  75. data/lib/ruby_llm/providers/gemini/capabilities.rb +276 -0
  76. data/lib/ruby_llm/providers/gemini/chat.rb +171 -0
  77. data/lib/ruby_llm/providers/gemini/embeddings.rb +37 -0
  78. data/lib/ruby_llm/providers/gemini/images.rb +47 -0
  79. data/lib/ruby_llm/providers/gemini/media.rb +54 -0
  80. data/lib/ruby_llm/providers/gemini/models.rb +40 -0
  81. data/lib/ruby_llm/providers/gemini/streaming.rb +61 -0
  82. data/lib/ruby_llm/providers/gemini/tools.rb +77 -0
  83. data/lib/ruby_llm/providers/gemini.rb +36 -0
  84. data/lib/ruby_llm/providers/gpustack/chat.rb +27 -0
  85. data/lib/ruby_llm/providers/gpustack/media.rb +45 -0
  86. data/lib/ruby_llm/providers/gpustack/models.rb +90 -0
  87. data/lib/ruby_llm/providers/gpustack.rb +34 -0
  88. data/lib/ruby_llm/providers/mistral/capabilities.rb +155 -0
  89. data/lib/ruby_llm/providers/mistral/chat.rb +24 -0
  90. data/lib/ruby_llm/providers/mistral/embeddings.rb +33 -0
  91. data/lib/ruby_llm/providers/mistral/models.rb +48 -0
  92. data/lib/ruby_llm/providers/mistral.rb +32 -0
  93. data/lib/ruby_llm/providers/ollama/chat.rb +27 -0
  94. data/lib/ruby_llm/providers/ollama/media.rb +45 -0
  95. data/lib/ruby_llm/providers/ollama/models.rb +36 -0
  96. data/lib/ruby_llm/providers/ollama.rb +30 -0
  97. data/lib/ruby_llm/providers/openai/capabilities.rb +291 -0
  98. data/lib/ruby_llm/providers/openai/chat.rb +83 -0
  99. data/lib/ruby_llm/providers/openai/embeddings.rb +33 -0
  100. data/lib/ruby_llm/providers/openai/images.rb +38 -0
  101. data/lib/ruby_llm/providers/openai/media.rb +80 -0
  102. data/lib/ruby_llm/providers/openai/models.rb +39 -0
  103. data/lib/ruby_llm/providers/openai/streaming.rb +41 -0
  104. data/lib/ruby_llm/providers/openai/tools.rb +78 -0
  105. data/lib/ruby_llm/providers/openai.rb +42 -0
  106. data/lib/ruby_llm/providers/openrouter/models.rb +73 -0
  107. data/lib/ruby_llm/providers/openrouter.rb +26 -0
  108. data/lib/ruby_llm/providers/perplexity/capabilities.rb +137 -0
  109. data/lib/ruby_llm/providers/perplexity/chat.rb +16 -0
  110. data/lib/ruby_llm/providers/perplexity/models.rb +42 -0
  111. data/lib/ruby_llm/providers/perplexity.rb +48 -0
  112. data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
  113. data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
  114. data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
  115. data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
  116. data/lib/ruby_llm/providers/vertexai.rb +55 -0
  117. data/lib/ruby_llm/railtie.rb +41 -0
  118. data/lib/ruby_llm/stream_accumulator.rb +97 -0
  119. data/lib/ruby_llm/streaming.rb +153 -0
  120. data/lib/ruby_llm/tool.rb +83 -0
  121. data/lib/ruby_llm/tool_call.rb +22 -0
  122. data/lib/ruby_llm/utils.rb +45 -0
  123. data/lib/ruby_llm/version.rb +5 -0
  124. data/lib/ruby_llm.rb +97 -0
  125. data/lib/tasks/models.rake +525 -0
  126. data/lib/tasks/release.rake +67 -0
  127. data/lib/tasks/ruby_llm.rake +15 -0
  128. data/lib/tasks/vcr.rake +92 -0
  129. metadata +291 -0
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module RubyLLM
7
+ class MigrateModelFieldsGenerator < Rails::Generators::Base # rubocop:disable Style/Documentation
8
+ include Rails::Generators::Migration
9
+
10
+ namespace 'ruby_llm:migrate_model_fields'
11
+ source_root File.expand_path('migrate_model_fields/templates', __dir__)
12
+
13
+ class_option :chat_model_name, type: :string, default: 'Chat'
14
+ class_option :message_model_name, type: :string, default: 'Message'
15
+ class_option :model_model_name, type: :string, default: 'Model'
16
+ class_option :tool_call_model_name, type: :string, default: 'ToolCall'
17
+
18
+ def self.next_migration_number(dirname)
19
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
20
+ end
21
+
22
+ def create_migration_file
23
+ migration_template 'migration.rb.tt',
24
+ 'db/migrate/migrate_to_ruby_llm_model_references.rb',
25
+ migration_version: migration_version
26
+ end
27
+
28
+ def create_model_file
29
+ # Check if Model file already exists
30
+ model_path = "app/models/#{options[:model_model_name].underscore}.rb"
31
+
32
+ if File.exist?(Rails.root.join(model_path))
33
+ say_status :skip, model_path, :yellow
34
+ else
35
+ create_file model_path do
36
+ <<~RUBY
37
+ class #{options[:model_model_name]} < ApplicationRecord
38
+ acts_as_model
39
+ end
40
+ RUBY
41
+ end
42
+ end
43
+ end
44
+
45
+ def acts_as_model_declaration
46
+ 'acts_as_model'
47
+ end
48
+
49
+ def update_initializer
50
+ say_status :info, 'Update your config/initializers/ruby_llm.rb:', :yellow
51
+ say <<~INSTRUCTIONS
52
+
53
+ Add this line to enable the DB-backed model registry:
54
+ config.model_registry_class = "#{options[:model_model_name]}"
55
+
56
+ INSTRUCTIONS
57
+ end
58
+
59
+ def show_next_steps
60
+ say_status :success, 'Migration created!', :green
61
+ say <<~INSTRUCTIONS
62
+
63
+ Next steps:
64
+ 1. Review the migration: db/migrate/*_migrate_to_ruby_llm_model_references.rb
65
+ 2. Run: rails db:migrate
66
+ 3. Update config/initializers/ruby_llm.rb as shown above
67
+ 4. Test your application thoroughly
68
+
69
+ The migration will:
70
+ - Create the Models table if it doesn't exist
71
+ - Load all models from models.json
72
+ - Migrate your existing data to use foreign keys
73
+ - Preserve all existing data (string columns renamed to model_id_string)
74
+
75
+ INSTRUCTIONS
76
+ end
77
+
78
+ private
79
+
80
+ def migration_version
81
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Adds chat and message persistence capabilities to ActiveRecord models.
6
+ module ActsAs
7
+ extend ActiveSupport::Concern
8
+
9
+ # When ActsAs is included, ensure models are loaded from database
10
+ def self.included(base)
11
+ super
12
+ # Monkey-patch Models to use database when ActsAs is active
13
+ RubyLLM::Models.class_eval do
14
+ def load_models
15
+ read_from_database
16
+ rescue StandardError => e
17
+ RubyLLM.logger.debug "Failed to load models from database: #{e.message}, falling back to JSON"
18
+ read_from_json
19
+ end
20
+
21
+ def load_from_database!
22
+ @models = read_from_database
23
+ end
24
+
25
+ def read_from_database
26
+ model_class = RubyLLM.config.model_registry_class
27
+ model_class = model_class.constantize if model_class.is_a?(String)
28
+ model_class.all.map(&:to_llm)
29
+ end
30
+ end
31
+ end
32
+
33
+ class_methods do # rubocop:disable Metrics/BlockLength
34
+ def acts_as_chat(message_class: 'Message', tool_call_class: 'ToolCall',
35
+ model_class: 'Model', model_foreign_key: nil)
36
+ include RubyLLM::ActiveRecord::ChatMethods
37
+
38
+ @message_class = message_class.to_s
39
+ @tool_call_class = tool_call_class.to_s
40
+ @model_class = model_class.to_s
41
+ @model_foreign_key = model_foreign_key || ActiveSupport::Inflector.foreign_key(@model_class)
42
+
43
+ has_many :messages,
44
+ -> { order(created_at: :asc) },
45
+ class_name: @message_class,
46
+ inverse_of: :chat,
47
+ dependent: :destroy
48
+
49
+ belongs_to :model,
50
+ class_name: @model_class,
51
+ foreign_key: @model_foreign_key,
52
+ optional: true
53
+
54
+ delegate :add_message, to: :to_llm
55
+ end
56
+
57
+ def acts_as_model(chat_class: 'Chat')
58
+ include RubyLLM::ActiveRecord::ModelMethods
59
+
60
+ @chat_class = chat_class.to_s
61
+
62
+ validates :model_id, presence: true, uniqueness: { scope: :provider }
63
+ validates :provider, presence: true
64
+ validates :name, presence: true
65
+
66
+ has_many :chats,
67
+ class_name: @chat_class,
68
+ foreign_key: ActiveSupport::Inflector.foreign_key(name)
69
+ end
70
+
71
+ def acts_as_message(chat_class: 'Chat', # rubocop:disable Metrics/ParameterLists
72
+ chat_foreign_key: nil,
73
+ tool_call_class: 'ToolCall',
74
+ tool_call_foreign_key: nil,
75
+ model_class: 'Model',
76
+ model_foreign_key: nil,
77
+ touch_chat: false)
78
+ include RubyLLM::ActiveRecord::MessageMethods
79
+
80
+ @chat_class = chat_class.to_s
81
+ @chat_foreign_key = chat_foreign_key || ActiveSupport::Inflector.foreign_key(@chat_class)
82
+
83
+ @tool_call_class = tool_call_class.to_s
84
+ @tool_call_foreign_key = tool_call_foreign_key || ActiveSupport::Inflector.foreign_key(@tool_call_class)
85
+
86
+ @model_class = model_class.to_s
87
+ @model_foreign_key = model_foreign_key || ActiveSupport::Inflector.foreign_key(@model_class)
88
+
89
+ belongs_to :chat,
90
+ class_name: @chat_class,
91
+ foreign_key: @chat_foreign_key,
92
+ inverse_of: :messages,
93
+ touch: touch_chat
94
+
95
+ has_many :tool_calls,
96
+ class_name: @tool_call_class,
97
+ dependent: :destroy
98
+
99
+ belongs_to :parent_tool_call,
100
+ class_name: @tool_call_class,
101
+ foreign_key: @tool_call_foreign_key,
102
+ optional: true,
103
+ inverse_of: :result
104
+
105
+ has_many :tool_results,
106
+ through: :tool_calls,
107
+ source: :result,
108
+ class_name: @message_class
109
+
110
+ belongs_to :model,
111
+ class_name: @model_class,
112
+ foreign_key: @model_foreign_key,
113
+ optional: true
114
+
115
+ delegate :tool_call?, :tool_result?, to: :to_llm
116
+ end
117
+
118
+ def acts_as_tool_call(message_class: 'Message', message_foreign_key: nil, result_foreign_key: nil)
119
+ @message_class = message_class.to_s
120
+ @message_foreign_key = message_foreign_key || ActiveSupport::Inflector.foreign_key(@message_class)
121
+ @result_foreign_key = result_foreign_key || ActiveSupport::Inflector.foreign_key(name)
122
+
123
+ belongs_to :message,
124
+ class_name: @message_class,
125
+ foreign_key: @message_foreign_key,
126
+ inverse_of: :tool_calls
127
+
128
+ has_one :result,
129
+ class_name: @message_class,
130
+ foreign_key: @result_foreign_key,
131
+ inverse_of: :parent_tool_call,
132
+ dependent: :nullify
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module ActiveRecord
5
+ # Adds chat and message persistence capabilities to ActiveRecord models.
6
+ module ActsAsLegacy
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do # rubocop:disable Metrics/BlockLength
10
+ def acts_as_chat(message_class: 'Message', tool_call_class: 'ToolCall')
11
+ include ChatLegacyMethods
12
+
13
+ @message_class = message_class.to_s
14
+ @tool_call_class = tool_call_class.to_s
15
+
16
+ has_many :messages,
17
+ -> { order(created_at: :asc) },
18
+ class_name: @message_class,
19
+ inverse_of: :chat,
20
+ dependent: :destroy
21
+
22
+ delegate :add_message, to: :to_llm
23
+ end
24
+
25
+ def acts_as_message(chat_class: 'Chat',
26
+ chat_foreign_key: nil,
27
+ tool_call_class: 'ToolCall',
28
+ tool_call_foreign_key: nil,
29
+ touch_chat: false)
30
+ include MessageLegacyMethods
31
+
32
+ @chat_class = chat_class.to_s
33
+ @chat_foreign_key = chat_foreign_key || ActiveSupport::Inflector.foreign_key(@chat_class)
34
+
35
+ @tool_call_class = tool_call_class.to_s
36
+ @tool_call_foreign_key = tool_call_foreign_key || ActiveSupport::Inflector.foreign_key(@tool_call_class)
37
+
38
+ belongs_to :chat,
39
+ class_name: @chat_class,
40
+ foreign_key: @chat_foreign_key,
41
+ inverse_of: :messages,
42
+ touch: touch_chat
43
+
44
+ has_many :tool_calls,
45
+ class_name: @tool_call_class,
46
+ dependent: :destroy
47
+
48
+ belongs_to :parent_tool_call,
49
+ class_name: @tool_call_class,
50
+ foreign_key: @tool_call_foreign_key,
51
+ optional: true,
52
+ inverse_of: :result
53
+
54
+ has_many :tool_results,
55
+ through: :tool_calls,
56
+ source: :result,
57
+ class_name: @message_class
58
+
59
+ delegate :tool_call?, :tool_result?, to: :to_llm
60
+ end
61
+
62
+ def acts_as_tool_call(message_class: 'Message', message_foreign_key: nil, result_foreign_key: nil)
63
+ @message_class = message_class.to_s
64
+ @message_foreign_key = message_foreign_key || ActiveSupport::Inflector.foreign_key(@message_class)
65
+ @result_foreign_key = result_foreign_key || ActiveSupport::Inflector.foreign_key(name)
66
+
67
+ belongs_to :message,
68
+ class_name: @message_class,
69
+ foreign_key: @message_foreign_key,
70
+ inverse_of: :tool_calls
71
+
72
+ has_one :result,
73
+ class_name: @message_class,
74
+ foreign_key: @result_foreign_key,
75
+ inverse_of: :parent_tool_call,
76
+ dependent: :nullify
77
+ end
78
+ end
79
+ end
80
+
81
+ # Methods mixed into chat models.
82
+ module ChatLegacyMethods
83
+ extend ActiveSupport::Concern
84
+
85
+ class_methods do
86
+ attr_reader :tool_call_class
87
+ end
88
+
89
+ def to_llm(context: nil)
90
+ # model_id is a string that RubyLLM can resolve
91
+ @chat ||= if context
92
+ context.chat(model: model_id)
93
+ else
94
+ RubyLLM.chat(model: model_id)
95
+ end
96
+ @chat.reset_messages!
97
+
98
+ messages.each do |msg|
99
+ @chat.add_message(msg.to_llm)
100
+ end
101
+
102
+ setup_persistence_callbacks
103
+ end
104
+
105
+ def with_instructions(instructions, replace: false)
106
+ transaction do
107
+ messages.where(role: :system).destroy_all if replace
108
+ messages.create!(role: :system, content: instructions)
109
+ end
110
+ to_llm.with_instructions(instructions)
111
+ self
112
+ end
113
+
114
+ def with_tool(...)
115
+ to_llm.with_tool(...)
116
+ self
117
+ end
118
+
119
+ def with_tools(...)
120
+ to_llm.with_tools(...)
121
+ self
122
+ end
123
+
124
+ def with_model(...)
125
+ update(model_id: to_llm.with_model(...).model.id)
126
+ self
127
+ end
128
+
129
+ def with_temperature(...)
130
+ to_llm.with_temperature(...)
131
+ self
132
+ end
133
+
134
+ def with_context(context)
135
+ to_llm(context: context)
136
+ self
137
+ end
138
+
139
+ def with_params(...)
140
+ to_llm.with_params(...)
141
+ self
142
+ end
143
+
144
+ def with_headers(...)
145
+ to_llm.with_headers(...)
146
+ self
147
+ end
148
+
149
+ def with_schema(...)
150
+ to_llm.with_schema(...)
151
+ self
152
+ end
153
+
154
+ def on_new_message(&block)
155
+ to_llm
156
+
157
+ existing_callback = @chat.instance_variable_get(:@on)[:new_message]
158
+
159
+ @chat.on_new_message do
160
+ existing_callback&.call
161
+ block&.call
162
+ end
163
+ self
164
+ end
165
+
166
+ def on_end_message(&block)
167
+ to_llm
168
+
169
+ existing_callback = @chat.instance_variable_get(:@on)[:end_message]
170
+
171
+ @chat.on_end_message do |msg|
172
+ existing_callback&.call(msg)
173
+ block&.call(msg)
174
+ end
175
+ self
176
+ end
177
+
178
+ def on_tool_call(...)
179
+ to_llm.on_tool_call(...)
180
+ self
181
+ end
182
+
183
+ def on_tool_result(...)
184
+ to_llm.on_tool_result(...)
185
+ self
186
+ end
187
+
188
+ def create_user_message(content, with: nil)
189
+ message_record = messages.create!(role: :user, content: content)
190
+ persist_content(message_record, with) if with.present?
191
+ message_record
192
+ end
193
+
194
+ def ask(message, with: nil, &)
195
+ create_user_message(message, with:)
196
+ complete(&)
197
+ end
198
+
199
+ alias say ask
200
+
201
+ def complete(...)
202
+ to_llm.complete(...)
203
+ rescue RubyLLM::Error => e
204
+ cleanup_failed_messages if @message&.persisted? && @message.content.blank?
205
+ cleanup_orphaned_tool_results
206
+ raise e
207
+ end
208
+
209
+ private
210
+
211
+ def cleanup_failed_messages
212
+ RubyLLM.logger.warn "RubyLLM: API call failed, destroying message: #{@message.id}"
213
+ @message.destroy
214
+ end
215
+
216
+ def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity
217
+ messages.reload
218
+ last = messages.order(:id).last
219
+
220
+ return unless last&.tool_call? || last&.tool_result?
221
+
222
+ if last.tool_call?
223
+ last.destroy
224
+ elsif last.tool_result?
225
+ tool_call_message = last.parent_tool_call.message
226
+ expected_results = tool_call_message.tool_calls.pluck(:id)
227
+ actual_results = tool_call_message.tool_results.pluck(:tool_call_id)
228
+
229
+ if expected_results.sort != actual_results.sort
230
+ tool_call_message.tool_results.each(&:destroy)
231
+ tool_call_message.destroy
232
+ end
233
+ end
234
+ end
235
+
236
+ def setup_persistence_callbacks
237
+ return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
238
+
239
+ @chat.on_new_message { persist_new_message }
240
+ @chat.on_end_message { |msg| persist_message_completion(msg) }
241
+
242
+ @chat.instance_variable_set(:@_persistence_callbacks_setup, true)
243
+ @chat
244
+ end
245
+
246
+ def persist_new_message
247
+ @message = messages.create!(role: :assistant, content: '')
248
+ end
249
+
250
+ def persist_message_completion(message) # rubocop:disable Metrics/PerceivedComplexity
251
+ return unless message
252
+
253
+ tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id
254
+
255
+ transaction do
256
+ content = message.content
257
+ attachments_to_persist = nil
258
+
259
+ if content.is_a?(RubyLLM::Content)
260
+ attachments_to_persist = content.attachments if content.attachments.any?
261
+ content = content.text
262
+ elsif content.is_a?(Hash) || content.is_a?(Array)
263
+ content = content.to_json
264
+ end
265
+
266
+ @message.update!(
267
+ role: message.role,
268
+ content: content,
269
+ model_id: message.model_id,
270
+ input_tokens: message.input_tokens,
271
+ output_tokens: message.output_tokens
272
+ )
273
+ @message.write_attribute(@message.class.tool_call_foreign_key, tool_call_id) if tool_call_id
274
+ @message.save!
275
+
276
+ persist_content(@message, attachments_to_persist) if attachments_to_persist
277
+ persist_tool_calls(message.tool_calls) if message.tool_calls.present?
278
+ end
279
+ end
280
+
281
+ def persist_tool_calls(tool_calls)
282
+ tool_calls.each_value do |tool_call|
283
+ attributes = tool_call.to_h
284
+ attributes[:tool_call_id] = attributes.delete(:id)
285
+ @message.tool_calls.create!(**attributes)
286
+ end
287
+ end
288
+
289
+ def find_tool_call_id(tool_call_id)
290
+ self.class.tool_call_class.constantize.find_by(tool_call_id: tool_call_id)&.id
291
+ end
292
+
293
+ def persist_content(message_record, attachments)
294
+ return unless message_record.respond_to?(:attachments)
295
+
296
+ attachables = prepare_for_active_storage(attachments)
297
+ message_record.attachments.attach(attachables) if attachables.any?
298
+ end
299
+
300
+ def prepare_for_active_storage(attachments)
301
+ Utils.to_safe_array(attachments).filter_map do |attachment|
302
+ case attachment
303
+ when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob
304
+ attachment
305
+ when ActiveStorage::Attached::One, ActiveStorage::Attached::Many
306
+ attachment.blobs
307
+ when Hash
308
+ attachment.values.map { |v| prepare_for_active_storage(v) }
309
+ else
310
+ convert_to_active_storage_format(attachment)
311
+ end
312
+ end.flatten.compact
313
+ end
314
+
315
+ def convert_to_active_storage_format(source)
316
+ return if source.blank?
317
+
318
+ attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source)
319
+
320
+ {
321
+ io: StringIO.new(attachment.content),
322
+ filename: attachment.filename,
323
+ content_type: attachment.mime_type
324
+ }
325
+ rescue StandardError => e
326
+ RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
327
+ nil
328
+ end
329
+ end
330
+
331
+ # Methods mixed into message models.
332
+ module MessageLegacyMethods
333
+ extend ActiveSupport::Concern
334
+
335
+ class_methods do
336
+ attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key
337
+ end
338
+
339
+ def to_llm
340
+ RubyLLM::Message.new(
341
+ role: role.to_sym,
342
+ content: extract_content,
343
+ tool_calls: extract_tool_calls,
344
+ tool_call_id: extract_tool_call_id,
345
+ input_tokens: input_tokens,
346
+ output_tokens: output_tokens,
347
+ model_id: model_id
348
+ )
349
+ end
350
+
351
+ private
352
+
353
+ def extract_tool_calls
354
+ tool_calls.to_h do |tool_call|
355
+ [
356
+ tool_call.tool_call_id,
357
+ RubyLLM::ToolCall.new(
358
+ id: tool_call.tool_call_id,
359
+ name: tool_call.name,
360
+ arguments: tool_call.arguments
361
+ )
362
+ ]
363
+ end
364
+ end
365
+
366
+ def extract_tool_call_id
367
+ parent_tool_call&.tool_call_id
368
+ end
369
+
370
+ def extract_content
371
+ return content unless respond_to?(:attachments) && attachments.attached?
372
+
373
+ RubyLLM::Content.new(content).tap do |content_obj|
374
+ @_tempfiles = []
375
+
376
+ attachments.each do |attachment|
377
+ tempfile = download_attachment(attachment)
378
+ content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
379
+ end
380
+ end
381
+ end
382
+
383
+ def download_attachment(attachment)
384
+ ext = File.extname(attachment.filename.to_s)
385
+ basename = File.basename(attachment.filename.to_s, ext)
386
+ tempfile = Tempfile.new([basename, ext])
387
+ tempfile.binmode
388
+
389
+ attachment.download { |chunk| tempfile.write(chunk) }
390
+
391
+ tempfile.flush
392
+ tempfile.rewind
393
+ @_tempfiles << tempfile
394
+ tempfile
395
+ end
396
+ end
397
+ end
398
+ end