ruby_llm_community 1.0.0 → 1.1.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +18 -1
  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/install/install_generator.rb +227 -0
  20. data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +2 -2
  21. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -4
  22. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +8 -7
  23. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +12 -3
  24. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +6 -5
  25. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +9 -8
  26. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -3
  27. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +2 -5
  28. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +2 -2
  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/upgrade_to_v1_7_generator.rb +170 -0
  31. data/lib/ruby_llm/active_record/acts_as.rb +108 -467
  32. data/lib/ruby_llm/active_record/acts_as_legacy.rb +403 -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 +72 -6
  37. data/lib/ruby_llm/attachment.rb +22 -0
  38. data/lib/ruby_llm/configuration.rb +6 -0
  39. data/lib/ruby_llm/image_attachment.rb +12 -3
  40. data/lib/ruby_llm/message.rb +1 -1
  41. data/lib/ruby_llm/models.json +2640 -1756
  42. data/lib/ruby_llm/models.rb +5 -15
  43. data/lib/ruby_llm/provider.rb +6 -4
  44. data/lib/ruby_llm/providers/anthropic/media.rb +1 -1
  45. data/lib/ruby_llm/providers/bedrock/models.rb +19 -1
  46. data/lib/ruby_llm/providers/gemini/media.rb +1 -1
  47. data/lib/ruby_llm/providers/gpustack/media.rb +1 -1
  48. data/lib/ruby_llm/providers/ollama/media.rb +1 -1
  49. data/lib/ruby_llm/providers/openai/media.rb +4 -4
  50. data/lib/ruby_llm/providers/openai/response.rb +7 -6
  51. data/lib/ruby_llm/providers/openai/response_media.rb +1 -1
  52. data/lib/ruby_llm/providers/openai/streaming.rb +14 -11
  53. data/lib/ruby_llm/providers/openai/tools.rb +11 -6
  54. data/lib/ruby_llm/providers/vertexai.rb +1 -1
  55. data/lib/ruby_llm/providers/xai/capabilities.rb +166 -0
  56. data/lib/ruby_llm/providers/xai/chat.rb +15 -0
  57. data/lib/ruby_llm/providers/xai/models.rb +48 -0
  58. data/lib/ruby_llm/providers/xai.rb +46 -0
  59. data/lib/ruby_llm/railtie.rb +20 -3
  60. data/lib/ruby_llm/stream_accumulator.rb +0 -4
  61. data/lib/ruby_llm/utils.rb +5 -9
  62. data/lib/ruby_llm/version.rb +1 -1
  63. data/lib/ruby_llm_community.rb +4 -3
  64. data/lib/tasks/models.rake +29 -5
  65. data/lib/tasks/ruby_llm.rake +15 -0
  66. data/lib/tasks/vcr.rake +2 -2
  67. metadata +32 -3
  68. data/lib/generators/ruby_llm/install/templates/INSTALL_INFO.md.tt +0 -108
  69. data/lib/generators/ruby_llm/install_generator.rb +0 -146
@@ -0,0 +1,403 @@
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 cache_prompts(...)
155
+ to_llm.cache_prompts(...)
156
+ self
157
+ end
158
+
159
+ def on_new_message(&block)
160
+ to_llm
161
+
162
+ existing_callback = @chat.instance_variable_get(:@on)[:new_message]
163
+
164
+ @chat.on_new_message do
165
+ existing_callback&.call
166
+ block&.call
167
+ end
168
+ self
169
+ end
170
+
171
+ def on_end_message(&block)
172
+ to_llm
173
+
174
+ existing_callback = @chat.instance_variable_get(:@on)[:end_message]
175
+
176
+ @chat.on_end_message do |msg|
177
+ existing_callback&.call(msg)
178
+ block&.call(msg)
179
+ end
180
+ self
181
+ end
182
+
183
+ def on_tool_call(...)
184
+ to_llm.on_tool_call(...)
185
+ self
186
+ end
187
+
188
+ def on_tool_result(...)
189
+ to_llm.on_tool_result(...)
190
+ self
191
+ end
192
+
193
+ def create_user_message(content, with: nil)
194
+ message_record = messages.create!(role: :user, content: content)
195
+ persist_content(message_record, with) if with.present?
196
+ message_record
197
+ end
198
+
199
+ def ask(message, with: nil, &)
200
+ create_user_message(message, with:)
201
+ complete(&)
202
+ end
203
+
204
+ alias say ask
205
+
206
+ def complete(...)
207
+ to_llm.complete(...)
208
+ rescue RubyLLM::Error => e
209
+ cleanup_failed_messages if @message&.persisted? && @message.content.blank?
210
+ cleanup_orphaned_tool_results
211
+ raise e
212
+ end
213
+
214
+ private
215
+
216
+ def cleanup_failed_messages
217
+ RubyLLM.logger.warn "RubyLLM: API call failed, destroying message: #{@message.id}"
218
+ @message.destroy
219
+ end
220
+
221
+ def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity
222
+ messages.reload
223
+ last = messages.order(:id).last
224
+
225
+ return unless last&.tool_call? || last&.tool_result?
226
+
227
+ if last.tool_call?
228
+ last.destroy
229
+ elsif last.tool_result?
230
+ tool_call_message = last.parent_tool_call.message
231
+ expected_results = tool_call_message.tool_calls.pluck(:id)
232
+ actual_results = tool_call_message.tool_results.pluck(:tool_call_id)
233
+
234
+ if expected_results.sort != actual_results.sort
235
+ tool_call_message.tool_results.each(&:destroy)
236
+ tool_call_message.destroy
237
+ end
238
+ end
239
+ end
240
+
241
+ def setup_persistence_callbacks
242
+ return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
243
+
244
+ @chat.on_new_message { persist_new_message }
245
+ @chat.on_end_message { |msg| persist_message_completion(msg) }
246
+
247
+ @chat.instance_variable_set(:@_persistence_callbacks_setup, true)
248
+ @chat
249
+ end
250
+
251
+ def persist_new_message
252
+ @message = messages.create!(role: :assistant, content: '')
253
+ end
254
+
255
+ def persist_message_completion(message) # rubocop:disable Metrics/PerceivedComplexity
256
+ return unless message
257
+
258
+ tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id
259
+
260
+ transaction do
261
+ content = message.content
262
+ attachments_to_persist = nil
263
+
264
+ if content.is_a?(RubyLLM::Content)
265
+ attachments_to_persist = content.attachments if content.attachments.any?
266
+ content = content.text
267
+ elsif content.is_a?(Hash) || content.is_a?(Array)
268
+ content = content.to_json
269
+ end
270
+
271
+ @message.update!(
272
+ role: message.role,
273
+ content: content,
274
+ model_id: message.model_id,
275
+ input_tokens: message.input_tokens,
276
+ output_tokens: message.output_tokens
277
+ )
278
+ @message.write_attribute(@message.class.tool_call_foreign_key, tool_call_id) if tool_call_id
279
+ @message.save!
280
+
281
+ persist_content(@message, attachments_to_persist) if attachments_to_persist
282
+ persist_tool_calls(message.tool_calls) if message.tool_calls.present?
283
+ end
284
+ end
285
+
286
+ def persist_tool_calls(tool_calls)
287
+ tool_calls.each_value do |tool_call|
288
+ attributes = tool_call.to_h
289
+ attributes[:tool_call_id] = attributes.delete(:id)
290
+ @message.tool_calls.create!(**attributes)
291
+ end
292
+ end
293
+
294
+ def find_tool_call_id(tool_call_id)
295
+ self.class.tool_call_class.constantize.find_by(tool_call_id: tool_call_id)&.id
296
+ end
297
+
298
+ def persist_content(message_record, attachments)
299
+ return unless message_record.respond_to?(:attachments)
300
+
301
+ attachables = prepare_for_active_storage(attachments)
302
+ message_record.attachments.attach(attachables) if attachables.any?
303
+ end
304
+
305
+ def prepare_for_active_storage(attachments)
306
+ Utils.to_safe_array(attachments).filter_map do |attachment|
307
+ case attachment
308
+ when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob
309
+ attachment
310
+ when ActiveStorage::Attached::One, ActiveStorage::Attached::Many
311
+ attachment.blobs
312
+ when Hash
313
+ attachment.values.map { |v| prepare_for_active_storage(v) }
314
+ else
315
+ convert_to_active_storage_format(attachment)
316
+ end
317
+ end.flatten.compact
318
+ end
319
+
320
+ def convert_to_active_storage_format(source)
321
+ return if source.blank?
322
+
323
+ attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source)
324
+
325
+ {
326
+ io: StringIO.new(attachment.content),
327
+ filename: attachment.filename,
328
+ content_type: attachment.mime_type
329
+ }
330
+ rescue StandardError => e
331
+ RubyLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
332
+ nil
333
+ end
334
+ end
335
+
336
+ # Methods mixed into message models.
337
+ module MessageLegacyMethods
338
+ extend ActiveSupport::Concern
339
+
340
+ class_methods do
341
+ attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key
342
+ end
343
+
344
+ def to_llm
345
+ RubyLLM::Message.new(
346
+ role: role.to_sym,
347
+ content: extract_content,
348
+ tool_calls: extract_tool_calls,
349
+ tool_call_id: extract_tool_call_id,
350
+ input_tokens: input_tokens,
351
+ output_tokens: output_tokens,
352
+ model_id: model_id
353
+ )
354
+ end
355
+
356
+ private
357
+
358
+ def extract_tool_calls
359
+ tool_calls.to_h do |tool_call|
360
+ [
361
+ tool_call.tool_call_id,
362
+ RubyLLM::ToolCall.new(
363
+ id: tool_call.tool_call_id,
364
+ name: tool_call.name,
365
+ arguments: tool_call.arguments
366
+ )
367
+ ]
368
+ end
369
+ end
370
+
371
+ def extract_tool_call_id
372
+ parent_tool_call&.tool_call_id
373
+ end
374
+
375
+ def extract_content
376
+ return content unless respond_to?(:attachments) && attachments.attached?
377
+
378
+ RubyLLM::Content.new(content).tap do |content_obj|
379
+ @_tempfiles = []
380
+
381
+ attachments.each do |attachment|
382
+ tempfile = download_attachment(attachment)
383
+ content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
384
+ end
385
+ end
386
+ end
387
+
388
+ def download_attachment(attachment)
389
+ ext = File.extname(attachment.filename.to_s)
390
+ basename = File.basename(attachment.filename.to_s, ext)
391
+ tempfile = Tempfile.new([basename, ext])
392
+ tempfile.binmode
393
+
394
+ attachment.download { |chunk| tempfile.write(chunk) }
395
+
396
+ tempfile.flush
397
+ tempfile.rewind
398
+ @_tempfiles << tempfile
399
+ tempfile
400
+ end
401
+ end
402
+ end
403
+ end