ruby_llm 1.6.4 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) 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 +115 -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/install/templates/chat_model.rb.tt +2 -2
  20. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -4
  21. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +8 -7
  22. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +43 -0
  23. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +6 -5
  24. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +10 -4
  25. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -3
  26. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
  27. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +2 -2
  28. data/lib/generators/ruby_llm/install_generator.rb +129 -33
  29. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +137 -0
  30. data/lib/generators/ruby_llm/upgrade_to_v1_7_generator.rb +160 -0
  31. data/lib/ruby_llm/active_record/acts_as.rb +111 -327
  32. data/lib/ruby_llm/active_record/acts_as_legacy.rb +398 -0
  33. data/lib/ruby_llm/active_record/chat_methods.rb +336 -0
  34. data/lib/ruby_llm/active_record/message_methods.rb +72 -0
  35. data/lib/ruby_llm/active_record/model_methods.rb +84 -0
  36. data/lib/ruby_llm/aliases.json +54 -13
  37. data/lib/ruby_llm/attachment.rb +20 -0
  38. data/lib/ruby_llm/chat.rb +5 -5
  39. data/lib/ruby_llm/configuration.rb +9 -0
  40. data/lib/ruby_llm/connection.rb +4 -4
  41. data/lib/ruby_llm/model/info.rb +12 -0
  42. data/lib/ruby_llm/models.json +3579 -2029
  43. data/lib/ruby_llm/models.rb +51 -22
  44. data/lib/ruby_llm/provider.rb +3 -3
  45. data/lib/ruby_llm/providers/anthropic/chat.rb +2 -2
  46. data/lib/ruby_llm/providers/anthropic/media.rb +1 -1
  47. data/lib/ruby_llm/providers/bedrock/chat.rb +2 -2
  48. data/lib/ruby_llm/providers/bedrock/models.rb +19 -1
  49. data/lib/ruby_llm/providers/gemini/chat.rb +1 -1
  50. data/lib/ruby_llm/providers/gemini/media.rb +1 -1
  51. data/lib/ruby_llm/providers/gpustack/chat.rb +11 -0
  52. data/lib/ruby_llm/providers/gpustack/media.rb +45 -0
  53. data/lib/ruby_llm/providers/gpustack/models.rb +44 -8
  54. data/lib/ruby_llm/providers/gpustack.rb +1 -0
  55. data/lib/ruby_llm/providers/ollama/media.rb +2 -6
  56. data/lib/ruby_llm/providers/ollama/models.rb +36 -0
  57. data/lib/ruby_llm/providers/ollama.rb +1 -0
  58. data/lib/ruby_llm/providers/openai/chat.rb +1 -1
  59. data/lib/ruby_llm/providers/openai/media.rb +4 -4
  60. data/lib/ruby_llm/providers/openai/tools.rb +11 -6
  61. data/lib/ruby_llm/providers/openai.rb +2 -2
  62. data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
  63. data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
  64. data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
  65. data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
  66. data/lib/ruby_llm/providers/vertexai.rb +55 -0
  67. data/lib/ruby_llm/railtie.rb +20 -3
  68. data/lib/ruby_llm/streaming.rb +1 -1
  69. data/lib/ruby_llm/utils.rb +5 -9
  70. data/lib/ruby_llm/version.rb +1 -1
  71. data/lib/ruby_llm.rb +4 -3
  72. data/lib/tasks/models.rake +39 -28
  73. data/lib/tasks/ruby_llm.rake +15 -0
  74. data/lib/tasks/vcr.rake +2 -2
  75. metadata +36 -2
  76. data/lib/generators/ruby_llm/install/templates/INSTALL_INFO.md.tt +0 -108
@@ -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