ruby_llm 1.6.4 → 1.7.1

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +6 -3
  3. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +127 -0
  4. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +39 -0
  5. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +24 -0
  6. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
  7. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
  8. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
  9. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
  10. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +16 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +23 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +10 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +9 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +16 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +30 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +18 -0
  19. data/lib/generators/ruby_llm/generator_helpers.rb +129 -0
  20. data/lib/generators/ruby_llm/install/install_generator.rb +104 -0
  21. data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +2 -2
  22. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -4
  23. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +8 -7
  24. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +40 -0
  25. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +6 -5
  26. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +10 -4
  27. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -3
  28. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
  29. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +2 -2
  30. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +145 -0
  31. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +121 -0
  32. data/lib/ruby_llm/active_record/acts_as.rb +111 -327
  33. data/lib/ruby_llm/active_record/acts_as_legacy.rb +398 -0
  34. data/lib/ruby_llm/active_record/chat_methods.rb +336 -0
  35. data/lib/ruby_llm/active_record/message_methods.rb +72 -0
  36. data/lib/ruby_llm/active_record/model_methods.rb +84 -0
  37. data/lib/ruby_llm/aliases.json +54 -13
  38. data/lib/ruby_llm/attachment.rb +20 -0
  39. data/lib/ruby_llm/chat.rb +5 -5
  40. data/lib/ruby_llm/configuration.rb +9 -0
  41. data/lib/ruby_llm/connection.rb +4 -4
  42. data/lib/ruby_llm/model/info.rb +12 -0
  43. data/lib/ruby_llm/models.json +3579 -2029
  44. data/lib/ruby_llm/models.rb +51 -22
  45. data/lib/ruby_llm/provider.rb +3 -3
  46. data/lib/ruby_llm/providers/anthropic/chat.rb +2 -2
  47. data/lib/ruby_llm/providers/anthropic/media.rb +1 -1
  48. data/lib/ruby_llm/providers/bedrock/chat.rb +2 -2
  49. data/lib/ruby_llm/providers/bedrock/models.rb +19 -1
  50. data/lib/ruby_llm/providers/gemini/chat.rb +1 -1
  51. data/lib/ruby_llm/providers/gemini/media.rb +1 -1
  52. data/lib/ruby_llm/providers/gpustack/chat.rb +11 -0
  53. data/lib/ruby_llm/providers/gpustack/media.rb +45 -0
  54. data/lib/ruby_llm/providers/gpustack/models.rb +44 -8
  55. data/lib/ruby_llm/providers/gpustack.rb +1 -0
  56. data/lib/ruby_llm/providers/ollama/media.rb +2 -6
  57. data/lib/ruby_llm/providers/ollama/models.rb +36 -0
  58. data/lib/ruby_llm/providers/ollama.rb +1 -0
  59. data/lib/ruby_llm/providers/openai/chat.rb +1 -1
  60. data/lib/ruby_llm/providers/openai/media.rb +4 -4
  61. data/lib/ruby_llm/providers/openai/tools.rb +11 -6
  62. data/lib/ruby_llm/providers/openai.rb +2 -2
  63. data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
  64. data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
  65. data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
  66. data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
  67. data/lib/ruby_llm/providers/vertexai.rb +55 -0
  68. data/lib/ruby_llm/railtie.rb +20 -3
  69. data/lib/ruby_llm/streaming.rb +1 -1
  70. data/lib/ruby_llm/utils.rb +5 -9
  71. data/lib/ruby_llm/version.rb +1 -1
  72. data/lib/ruby_llm.rb +4 -3
  73. data/lib/tasks/models.rake +39 -28
  74. data/lib/tasks/ruby_llm.rake +15 -0
  75. data/lib/tasks/vcr.rake +2 -2
  76. metadata +38 -3
  77. data/lib/generators/ruby_llm/install/templates/INSTALL_INFO.md.tt +0 -108
  78. data/lib/generators/ruby_llm/install_generator.rb +0 -121
@@ -6,378 +6,162 @@ module RubyLLM
6
6
  module ActsAs
7
7
  extend ActiveSupport::Concern
8
8
 
9
- class_methods do # rubocop:disable Metrics/BlockLength
10
- def acts_as_chat(message_class: 'Message', tool_call_class: 'ToolCall')
11
- include ChatMethods
12
-
13
- @message_class = message_class.to_s
14
- @tool_call_class = tool_call_class.to_s
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
15
20
 
16
- has_many :messages,
17
- -> { order(created_at: :asc) },
18
- class_name: @message_class,
19
- inverse_of: :chat,
20
- dependent: :destroy
21
+ def load_from_database!
22
+ @models = read_from_database
23
+ end
21
24
 
22
- delegate :add_message, to: :to_llm
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
23
30
  end
31
+ end
24
32
 
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 MessageMethods
33
+ class_methods do # rubocop:disable Metrics/BlockLength
34
+ def acts_as_chat(messages: :messages, message_class: nil,
35
+ model: :model, model_class: nil)
36
+ include RubyLLM::ActiveRecord::ChatMethods
31
37
 
32
- @chat_class = chat_class.to_s
33
- @chat_foreign_key = chat_foreign_key || ActiveSupport::Inflector.foreign_key(@chat_class)
38
+ class_attribute :messages_association_name, :model_association_name, :message_class, :model_class
34
39
 
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)
40
+ self.messages_association_name = messages
41
+ self.model_association_name = model
42
+ self.message_class = (message_class || messages.to_s.classify).to_s
43
+ self.model_class = (model_class || model.to_s.classify).to_s
37
44
 
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,
45
+ has_many messages,
46
+ -> { order(created_at: :asc) },
47
+ class_name: self.message_class,
46
48
  dependent: :destroy
47
49
 
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
- delegate :tool_call?, :tool_result?, :tool_results, to: :to_llm
55
- end
56
-
57
- def acts_as_tool_call(message_class: 'Message', message_foreign_key: nil, result_foreign_key: nil)
58
- @message_class = message_class.to_s
59
- @message_foreign_key = message_foreign_key || ActiveSupport::Inflector.foreign_key(@message_class)
60
- @result_foreign_key = result_foreign_key || ActiveSupport::Inflector.foreign_key(name)
61
-
62
- belongs_to :message,
63
- class_name: @message_class,
64
- foreign_key: @message_foreign_key,
65
- inverse_of: :tool_calls
66
-
67
- has_one :result,
68
- class_name: @message_class,
69
- foreign_key: @result_foreign_key,
70
- inverse_of: :parent_tool_call,
71
- dependent: :nullify
72
- end
73
- end
74
- end
75
-
76
- # Methods mixed into chat models.
77
- module ChatMethods
78
- extend ActiveSupport::Concern
79
-
80
- class_methods do
81
- attr_reader :tool_call_class
82
- end
50
+ belongs_to model,
51
+ class_name: self.model_class,
52
+ optional: true
83
53
 
84
- def to_llm(context: nil)
85
- @chat ||= if context
86
- context.chat(model: model_id)
87
- else
88
- RubyLLM.chat(model: model_id)
89
- end
90
- @chat.reset_messages!
54
+ delegate :add_message, to: :to_llm
91
55
 
92
- messages.each do |msg|
93
- @chat.add_message(msg.to_llm)
94
- end
56
+ define_method :messages_association do
57
+ send(messages_association_name)
58
+ end
95
59
 
96
- setup_persistence_callbacks
97
- end
60
+ define_method :model_association do
61
+ send(model_association_name)
62
+ end
98
63
 
99
- def with_instructions(instructions, replace: false)
100
- transaction do
101
- messages.where(role: :system).destroy_all if replace
102
- messages.create!(role: :system, content: instructions)
64
+ define_method :'model_association=' do |value|
65
+ send("#{model_association_name}=", value)
66
+ end
103
67
  end
104
- to_llm.with_instructions(instructions)
105
- self
106
- end
107
-
108
- def with_tool(...)
109
- to_llm.with_tool(...)
110
- self
111
- end
112
-
113
- def with_tools(...)
114
- to_llm.with_tools(...)
115
- self
116
- end
117
-
118
- def with_model(...)
119
- update(model_id: to_llm.with_model(...).model.id)
120
- self
121
- end
122
68
 
123
- def with_temperature(...)
124
- to_llm.with_temperature(...)
125
- self
126
- end
127
-
128
- def with_context(context)
129
- to_llm(context: context)
130
- self
131
- end
69
+ def acts_as_model(chats: :chats, chat_class: nil)
70
+ include RubyLLM::ActiveRecord::ModelMethods
132
71
 
133
- def with_params(...)
134
- to_llm.with_params(...)
135
- self
136
- end
72
+ class_attribute :chats_association_name, :chat_class
137
73
 
138
- def with_headers(...)
139
- to_llm.with_headers(...)
140
- self
141
- end
142
-
143
- def with_schema(...)
144
- to_llm.with_schema(...)
145
- self
146
- end
74
+ self.chats_association_name = chats
75
+ self.chat_class = (chat_class || chats.to_s.classify).to_s
147
76
 
148
- def on_new_message(&block)
149
- to_llm
77
+ validates :model_id, presence: true, uniqueness: { scope: :provider }
78
+ validates :provider, presence: true
79
+ validates :name, presence: true
150
80
 
151
- existing_callback = @chat.instance_variable_get(:@on)[:new_message]
81
+ has_many chats, class_name: self.chat_class
152
82
 
153
- @chat.on_new_message do
154
- existing_callback&.call
155
- block&.call
156
- end
157
- self
158
- end
159
-
160
- def on_end_message(&block)
161
- to_llm
162
-
163
- existing_callback = @chat.instance_variable_get(:@on)[:end_message]
164
-
165
- @chat.on_end_message do |msg|
166
- existing_callback&.call(msg)
167
- block&.call(msg)
83
+ define_method :chats_association do
84
+ send(chats_association_name)
85
+ end
168
86
  end
169
- self
170
- end
171
-
172
- def on_tool_call(...)
173
- to_llm.on_tool_call(...)
174
- self
175
- end
176
-
177
- def on_tool_result(...)
178
- to_llm.on_tool_result(...)
179
- self
180
- end
181
-
182
- def create_user_message(content, with: nil)
183
- message_record = messages.create!(role: :user, content: content)
184
- persist_content(message_record, with) if with.present?
185
- message_record
186
- end
187
87
 
188
- def ask(message, with: nil, &)
189
- create_user_message(message, with:)
190
- complete(&)
191
- end
192
-
193
- alias say ask
194
-
195
- def complete(...)
196
- to_llm.complete(...)
197
- rescue RubyLLM::Error => e
198
- cleanup_failed_messages if @message&.persisted? && @message.content.blank?
199
- cleanup_orphaned_tool_results
200
- raise e
201
- end
202
-
203
- private
204
-
205
- def cleanup_failed_messages
206
- RubyLLM.logger.debug "RubyLLM: API call failed, destroying message: #{@message.id}"
207
- @message.destroy
208
- end
88
+ def acts_as_message(chat: :chat, chat_class: nil, touch_chat: false, # rubocop:disable Metrics/ParameterLists
89
+ tool_calls: :tool_calls, tool_call_class: nil,
90
+ model: :model, model_class: nil)
91
+ include RubyLLM::ActiveRecord::MessageMethods
209
92
 
210
- def cleanup_orphaned_tool_results
211
- loop do
212
- messages.reload
213
- last = messages.order(:id).last
93
+ class_attribute :chat_association_name, :tool_calls_association_name, :model_association_name,
94
+ :chat_class, :tool_call_class, :model_class
214
95
 
215
- break unless last&.tool_call? || last&.tool_result?
96
+ self.chat_association_name = chat
97
+ self.tool_calls_association_name = tool_calls
98
+ self.model_association_name = model
99
+ self.chat_class = (chat_class || chat.to_s.classify).to_s
100
+ self.tool_call_class = (tool_call_class || tool_calls.to_s.classify).to_s
101
+ self.model_class = (model_class || model.to_s.classify).to_s
216
102
 
217
- last.destroy
218
- end
219
- end
103
+ belongs_to chat,
104
+ class_name: self.chat_class,
105
+ touch: touch_chat
220
106
 
221
- def setup_persistence_callbacks
222
- return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
107
+ has_many tool_calls,
108
+ class_name: self.tool_call_class,
109
+ dependent: :destroy
223
110
 
224
- @chat.on_new_message { persist_new_message }
225
- @chat.on_end_message { |msg| persist_message_completion(msg) }
111
+ belongs_to :parent_tool_call,
112
+ class_name: self.tool_call_class,
113
+ foreign_key: ActiveSupport::Inflector.foreign_key(tool_calls.to_s.singularize),
114
+ optional: true
226
115
 
227
- @chat.instance_variable_set(:@_persistence_callbacks_setup, true)
228
- @chat
229
- end
116
+ has_many :tool_results,
117
+ through: tool_calls,
118
+ source: :result,
119
+ class_name: name
230
120
 
231
- def persist_new_message
232
- @message = messages.create!(role: :assistant, content: '')
233
- end
121
+ belongs_to model,
122
+ class_name: self.model_class,
123
+ optional: true
234
124
 
235
- def persist_message_completion(message) # rubocop:disable Metrics/PerceivedComplexity
236
- return unless message
125
+ delegate :tool_call?, :tool_result?, to: :to_llm
237
126
 
238
- tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id
239
-
240
- transaction do
241
- content = message.content
242
- attachments_to_persist = nil
127
+ define_method :chat_association do
128
+ send(chat_association_name)
129
+ end
243
130
 
244
- if content.is_a?(RubyLLM::Content)
245
- attachments_to_persist = content.attachments if content.attachments.any?
246
- content = content.text
247
- elsif content.is_a?(Hash) || content.is_a?(Array)
248
- content = content.to_json
131
+ define_method :tool_calls_association do
132
+ send(tool_calls_association_name)
249
133
  end
250
134
 
251
- @message.update!(
252
- role: message.role,
253
- content: content,
254
- model_id: message.model_id,
255
- input_tokens: message.input_tokens,
256
- output_tokens: message.output_tokens
257
- )
258
- @message.write_attribute(@message.class.tool_call_foreign_key, tool_call_id) if tool_call_id
259
- @message.save!
260
-
261
- persist_content(@message, attachments_to_persist) if attachments_to_persist
262
- persist_tool_calls(message.tool_calls) if message.tool_calls.present?
135
+ define_method :model_association do
136
+ send(model_association_name)
137
+ end
263
138
  end
264
- end
265
139
 
266
- def persist_tool_calls(tool_calls)
267
- tool_calls.each_value do |tool_call|
268
- attributes = tool_call.to_h
269
- attributes[:tool_call_id] = attributes.delete(:id)
270
- @message.tool_calls.create!(**attributes)
271
- end
272
- end
140
+ def acts_as_tool_call(message: :message, message_class: nil,
141
+ result: :result, result_class: nil)
142
+ class_attribute :message_association_name, :result_association_name, :message_class, :result_class
273
143
 
274
- def find_tool_call_id(tool_call_id)
275
- self.class.tool_call_class.constantize.find_by(tool_call_id: tool_call_id)&.id
276
- end
144
+ self.message_association_name = message
145
+ self.result_association_name = result
146
+ self.message_class = (message_class || message.to_s.classify).to_s
147
+ self.result_class = (result_class || self.message_class).to_s
277
148
 
278
- def persist_content(message_record, attachments)
279
- return unless message_record.respond_to?(:attachments)
149
+ belongs_to message,
150
+ class_name: self.message_class
280
151
 
281
- attachables = prepare_for_active_storage(attachments)
282
- message_record.attachments.attach(attachables) if attachables.any?
283
- end
152
+ has_one result,
153
+ class_name: self.result_class,
154
+ dependent: :nullify
284
155
 
285
- def prepare_for_active_storage(attachments)
286
- Utils.to_safe_array(attachments).filter_map do |attachment|
287
- case attachment
288
- when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob
289
- attachment
290
- when ActiveStorage::Attached::One, ActiveStorage::Attached::Many
291
- attachment.blobs
292
- when Hash
293
- attachment.values.map { |v| prepare_for_active_storage(v) }
294
- else
295
- convert_to_active_storage_format(attachment)
156
+ define_method :message_association do
157
+ send(message_association_name)
296
158
  end
297
- end.flatten.compact
298
- end
299
-
300
- def convert_to_active_storage_format(source)
301
- return if source.blank?
302
-
303
- attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source)
304
-
305
- {
306
- io: StringIO.new(attachment.content),
307
- filename: attachment.filename,
308
- content_type: attachment.mime_type
309
- }
310
- rescue StandardError => e
311
- RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
312
- nil
313
- end
314
- end
315
-
316
- # Methods mixed into message models.
317
- module MessageMethods
318
- extend ActiveSupport::Concern
319
-
320
- class_methods do
321
- attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key
322
- end
323
159
 
324
- def to_llm
325
- RubyLLM::Message.new(
326
- role: role.to_sym,
327
- content: extract_content,
328
- tool_calls: extract_tool_calls,
329
- tool_call_id: extract_tool_call_id,
330
- input_tokens: input_tokens,
331
- output_tokens: output_tokens,
332
- model_id: model_id
333
- )
334
- end
335
-
336
- private
337
-
338
- def extract_tool_calls
339
- tool_calls.to_h do |tool_call|
340
- [
341
- tool_call.tool_call_id,
342
- RubyLLM::ToolCall.new(
343
- id: tool_call.tool_call_id,
344
- name: tool_call.name,
345
- arguments: tool_call.arguments
346
- )
347
- ]
348
- end
349
- end
350
-
351
- def extract_tool_call_id
352
- parent_tool_call&.tool_call_id
353
- end
354
-
355
- def extract_content
356
- return content unless respond_to?(:attachments) && attachments.attached?
357
-
358
- RubyLLM::Content.new(content).tap do |content_obj|
359
- @_tempfiles = []
360
-
361
- attachments.each do |attachment|
362
- tempfile = download_attachment(attachment)
363
- content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
160
+ define_method :result_association do
161
+ send(result_association_name)
364
162
  end
365
163
  end
366
164
  end
367
-
368
- def download_attachment(attachment)
369
- ext = File.extname(attachment.filename.to_s)
370
- basename = File.basename(attachment.filename.to_s, ext)
371
- tempfile = Tempfile.new([basename, ext])
372
- tempfile.binmode
373
-
374
- attachment.download { |chunk| tempfile.write(chunk) }
375
-
376
- tempfile.flush
377
- tempfile.rewind
378
- @_tempfiles << tempfile
379
- tempfile
380
- end
381
165
  end
382
166
  end
383
167
  end