lex-llm 0.1.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 (135) hide show
  1. checksums.yaml +7 -0
  2. data/.github/CODEOWNERS +7 -0
  3. data/.github/dependabot.yml +18 -0
  4. data/.github/workflows/ci.yml +16 -0
  5. data/.gitignore +19 -0
  6. data/.rubocop.yml +42 -0
  7. data/CHANGELOG.md +15 -0
  8. data/Gemfile +50 -0
  9. data/LICENSE +21 -0
  10. data/README.md +279 -0
  11. data/lex-llm.gemspec +43 -0
  12. data/lib/generators/lex_llm/agent/agent_generator.rb +36 -0
  13. data/lib/generators/lex_llm/agent/templates/agent.rb.tt +6 -0
  14. data/lib/generators/lex_llm/agent/templates/instructions.txt.erb.tt +0 -0
  15. data/lib/generators/lex_llm/chat_ui/chat_ui_generator.rb +256 -0
  16. data/lib/generators/lex_llm/chat_ui/templates/controllers/chats_controller.rb.tt +38 -0
  17. data/lib/generators/lex_llm/chat_ui/templates/controllers/messages_controller.rb.tt +21 -0
  18. data/lib/generators/lex_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
  19. data/lib/generators/lex_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
  20. data/lib/generators/lex_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
  21. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
  22. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
  23. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
  24. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
  25. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
  26. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
  27. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
  28. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
  29. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
  30. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
  31. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
  32. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
  33. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
  34. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
  35. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
  36. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
  37. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
  38. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
  39. data/lib/generators/lex_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
  40. data/lib/generators/lex_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
  41. data/lib/generators/lex_llm/chat_ui/templates/views/chats/index.html.erb.tt +28 -0
  42. data/lib/generators/lex_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
  43. data/lib/generators/lex_llm/chat_ui/templates/views/chats/show.html.erb.tt +25 -0
  44. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
  45. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -0
  46. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
  47. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
  48. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
  49. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
  50. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -0
  51. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
  52. data/lib/generators/lex_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +7 -0
  53. data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
  54. data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
  55. data/lib/generators/lex_llm/chat_ui/templates/views/models/_model.html.erb.tt +15 -0
  56. data/lib/generators/lex_llm/chat_ui/templates/views/models/index.html.erb.tt +38 -0
  57. data/lib/generators/lex_llm/chat_ui/templates/views/models/show.html.erb.tt +17 -0
  58. data/lib/generators/lex_llm/generator_helpers.rb +214 -0
  59. data/lib/generators/lex_llm/install/install_generator.rb +109 -0
  60. data/lib/generators/lex_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +9 -0
  61. data/lib/generators/lex_llm/install/templates/chat_model.rb.tt +3 -0
  62. data/lib/generators/lex_llm/install/templates/create_chats_migration.rb.tt +7 -0
  63. data/lib/generators/lex_llm/install/templates/create_messages_migration.rb.tt +19 -0
  64. data/lib/generators/lex_llm/install/templates/create_models_migration.rb.tt +39 -0
  65. data/lib/generators/lex_llm/install/templates/create_tool_calls_migration.rb.tt +21 -0
  66. data/lib/generators/lex_llm/install/templates/initializer.rb.tt +20 -0
  67. data/lib/generators/lex_llm/install/templates/message_model.rb.tt +4 -0
  68. data/lib/generators/lex_llm/install/templates/model_model.rb.tt +3 -0
  69. data/lib/generators/lex_llm/install/templates/tool_call_model.rb.tt +3 -0
  70. data/lib/generators/lex_llm/schema/schema_generator.rb +26 -0
  71. data/lib/generators/lex_llm/schema/templates/schema.rb.tt +2 -0
  72. data/lib/generators/lex_llm/tool/templates/tool.rb.tt +9 -0
  73. data/lib/generators/lex_llm/tool/templates/tool_call.html.erb.tt +13 -0
  74. data/lib/generators/lex_llm/tool/templates/tool_result.html.erb.tt +13 -0
  75. data/lib/generators/lex_llm/tool/tool_generator.rb +96 -0
  76. data/lib/generators/lex_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +19 -0
  77. data/lib/generators/lex_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +50 -0
  78. data/lib/generators/lex_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  79. data/lib/generators/lex_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  80. data/lib/generators/lex_llm/upgrade_to_v1_7/templates/migration.rb.tt +145 -0
  81. data/lib/generators/lex_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +122 -0
  82. data/lib/generators/lex_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +15 -0
  83. data/lib/generators/lex_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +49 -0
  84. data/lib/legion/extensions/llm/provider_settings.rb +49 -0
  85. data/lib/legion/extensions/llm/transport/fleet_lane.rb +70 -0
  86. data/lib/legion/extensions/llm.rb +50 -0
  87. data/lib/lex_llm/active_record/acts_as.rb +180 -0
  88. data/lib/lex_llm/active_record/acts_as_legacy.rb +503 -0
  89. data/lib/lex_llm/active_record/chat_methods.rb +468 -0
  90. data/lib/lex_llm/active_record/message_methods.rb +131 -0
  91. data/lib/lex_llm/active_record/model_methods.rb +76 -0
  92. data/lib/lex_llm/active_record/payload_helpers.rb +26 -0
  93. data/lib/lex_llm/active_record/tool_call_methods.rb +15 -0
  94. data/lib/lex_llm/agent.rb +365 -0
  95. data/lib/lex_llm/aliases.json +436 -0
  96. data/lib/lex_llm/aliases.rb +38 -0
  97. data/lib/lex_llm/attachment.rb +223 -0
  98. data/lib/lex_llm/chat.rb +351 -0
  99. data/lib/lex_llm/chunk.rb +6 -0
  100. data/lib/lex_llm/configuration.rb +81 -0
  101. data/lib/lex_llm/connection.rb +130 -0
  102. data/lib/lex_llm/content.rb +77 -0
  103. data/lib/lex_llm/context.rb +29 -0
  104. data/lib/lex_llm/embedding.rb +29 -0
  105. data/lib/lex_llm/error.rb +112 -0
  106. data/lib/lex_llm/image.rb +105 -0
  107. data/lib/lex_llm/message.rb +107 -0
  108. data/lib/lex_llm/mime_type.rb +71 -0
  109. data/lib/lex_llm/model/info.rb +113 -0
  110. data/lib/lex_llm/model/modalities.rb +22 -0
  111. data/lib/lex_llm/model/pricing.rb +48 -0
  112. data/lib/lex_llm/model/pricing_category.rb +46 -0
  113. data/lib/lex_llm/model/pricing_tier.rb +33 -0
  114. data/lib/lex_llm/model.rb +7 -0
  115. data/lib/lex_llm/models.json +57241 -0
  116. data/lib/lex_llm/models.rb +506 -0
  117. data/lib/lex_llm/models_schema.json +168 -0
  118. data/lib/lex_llm/moderation.rb +56 -0
  119. data/lib/lex_llm/provider.rb +278 -0
  120. data/lib/lex_llm/railtie.rb +35 -0
  121. data/lib/lex_llm/routing/lane_key.rb +51 -0
  122. data/lib/lex_llm/routing/model_offering.rb +169 -0
  123. data/lib/lex_llm/routing.rb +7 -0
  124. data/lib/lex_llm/stream_accumulator.rb +203 -0
  125. data/lib/lex_llm/streaming.rb +175 -0
  126. data/lib/lex_llm/thinking.rb +49 -0
  127. data/lib/lex_llm/tokens.rb +47 -0
  128. data/lib/lex_llm/tool.rb +254 -0
  129. data/lib/lex_llm/tool_call.rb +25 -0
  130. data/lib/lex_llm/transcription.rb +35 -0
  131. data/lib/lex_llm/utils.rb +91 -0
  132. data/lib/lex_llm/version.rb +5 -0
  133. data/lib/lex_llm.rb +95 -0
  134. data/lib/tasks/lex_llm.rake +23 -0
  135. metadata +349 -0
@@ -0,0 +1,503 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
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
+ end
22
+
23
+ def acts_as_message(chat_class: 'Chat',
24
+ chat_foreign_key: nil,
25
+ tool_call_class: 'ToolCall',
26
+ tool_call_foreign_key: nil,
27
+ touch_chat: false)
28
+ include MessageLegacyMethods
29
+
30
+ @chat_class = chat_class.to_s
31
+ @chat_foreign_key = chat_foreign_key || ActiveSupport::Inflector.foreign_key(@chat_class)
32
+
33
+ @tool_call_class = tool_call_class.to_s
34
+ @tool_call_foreign_key = tool_call_foreign_key || ActiveSupport::Inflector.foreign_key(@tool_call_class)
35
+
36
+ belongs_to :chat,
37
+ class_name: @chat_class,
38
+ foreign_key: @chat_foreign_key,
39
+ inverse_of: :messages,
40
+ touch: touch_chat
41
+
42
+ has_many :tool_calls,
43
+ class_name: @tool_call_class,
44
+ dependent: :destroy
45
+
46
+ belongs_to :parent_tool_call,
47
+ class_name: @tool_call_class,
48
+ foreign_key: @tool_call_foreign_key,
49
+ optional: true,
50
+ inverse_of: :result
51
+
52
+ has_many :tool_results,
53
+ through: :tool_calls,
54
+ source: :result,
55
+ class_name: @message_class
56
+
57
+ delegate :tool_call?, :tool_result?, to: :to_llm
58
+ end
59
+
60
+ def acts_as_tool_call(message_class: 'Message', message_foreign_key: nil, result_foreign_key: nil)
61
+ include LexLLM::ActiveRecord::ToolCallMethods
62
+
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 LexLLM can resolve
91
+ @chat ||= if context
92
+ context.chat(model: model_id)
93
+ else
94
+ LexLLM.chat(model: model_id)
95
+ end
96
+ @chat.reset_messages!
97
+
98
+ ordered_messages = order_messages_for_llm(messages.to_a)
99
+ ordered_messages.each do |msg|
100
+ @chat.add_message(msg.to_llm)
101
+ end
102
+ reapply_runtime_instructions(@chat)
103
+
104
+ setup_persistence_callbacks
105
+ end
106
+
107
+ def with_instructions(instructions, append: false, replace: nil)
108
+ append = append_instructions?(append:, replace:)
109
+ persist_system_instruction(instructions, append:)
110
+
111
+ to_llm.with_instructions(instructions, append:, replace:)
112
+ self
113
+ end
114
+
115
+ def with_runtime_instructions(instructions, append: false, replace: nil)
116
+ append = append_instructions?(append:, replace:)
117
+ store_runtime_instruction(instructions, append:)
118
+
119
+ to_llm.with_instructions(instructions, append:, replace:)
120
+ self
121
+ end
122
+
123
+ def with_tool(...)
124
+ to_llm.with_tool(...)
125
+ self
126
+ end
127
+
128
+ def with_tools(...)
129
+ to_llm.with_tools(...)
130
+ self
131
+ end
132
+
133
+ def with_model(...)
134
+ update(model_id: to_llm.with_model(...).model.id)
135
+ self
136
+ end
137
+
138
+ def with_temperature(...)
139
+ to_llm.with_temperature(...)
140
+ self
141
+ end
142
+
143
+ def with_context(context)
144
+ to_llm(context: context)
145
+ self
146
+ end
147
+
148
+ def with_params(...)
149
+ to_llm.with_params(...)
150
+ self
151
+ end
152
+
153
+ def with_headers(...)
154
+ to_llm.with_headers(...)
155
+ self
156
+ end
157
+
158
+ def with_schema(...)
159
+ to_llm.with_schema(...)
160
+ self
161
+ end
162
+
163
+ def on_new_message(&block)
164
+ to_llm
165
+
166
+ existing_callback = @chat.instance_variable_get(:@on)[:new_message]
167
+
168
+ @chat.on_new_message do
169
+ existing_callback&.call
170
+ block&.call
171
+ end
172
+ self
173
+ end
174
+
175
+ def on_end_message(&block)
176
+ to_llm
177
+
178
+ existing_callback = @chat.instance_variable_get(:@on)[:end_message]
179
+
180
+ @chat.on_end_message do |msg|
181
+ existing_callback&.call(msg)
182
+ block&.call(msg)
183
+ end
184
+ self
185
+ end
186
+
187
+ def on_tool_call(...)
188
+ to_llm.on_tool_call(...)
189
+ self
190
+ end
191
+
192
+ def on_tool_result(...)
193
+ to_llm.on_tool_result(...)
194
+ self
195
+ end
196
+
197
+ def add_message(message_or_attributes)
198
+ llm_message = message_or_attributes.is_a?(LexLLM::Message) ? message_or_attributes : LexLLM::Message.new(message_or_attributes)
199
+ content, attachments_to_persist = prepare_content_for_storage(llm_message.content)
200
+
201
+ attrs = { role: llm_message.role, content: }
202
+ tool_call_foreign_key = messages.klass.tool_call_foreign_key
203
+ if llm_message.tool_call_id && tool_call_foreign_key
204
+ tool_call_id = find_tool_call_id(llm_message.tool_call_id)
205
+ attrs[tool_call_foreign_key] = tool_call_id if tool_call_id
206
+ end
207
+
208
+ message_record = messages.create!(attrs)
209
+ persist_content(message_record, attachments_to_persist) if attachments_to_persist.present?
210
+ persist_tool_calls(llm_message.tool_calls, message_record:) if llm_message.tool_calls.present?
211
+
212
+ message_record
213
+ end
214
+
215
+ def create_user_message(content, with: nil)
216
+ LexLLM.logger.warn(
217
+ '`create_user_message` is deprecated and will be removed in LexLLM 2.0. ' \
218
+ 'Use `add_message(role: :user, content: ...)` instead.'
219
+ )
220
+ add_message(role: :user, content: build_content(content, with))
221
+ end
222
+
223
+ def ask(message = nil, with: nil, &)
224
+ add_message(role: :user, content: build_content(message, with))
225
+ complete(&)
226
+ end
227
+
228
+ alias say ask
229
+
230
+ def complete(...)
231
+ to_llm.complete(...)
232
+ rescue LexLLM::Error => e
233
+ cleanup_failed_messages if @message&.persisted? && @message.content.blank?
234
+ cleanup_orphaned_tool_results
235
+ raise e
236
+ end
237
+
238
+ private
239
+
240
+ def cleanup_failed_messages
241
+ LexLLM.logger.warn "LexLLM: API call failed, destroying message: #{@message.id}"
242
+ @message.destroy
243
+ end
244
+
245
+ def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity
246
+ messages.reload
247
+ last = messages.order(:id).last
248
+
249
+ return unless last&.tool_call? || last&.tool_result?
250
+
251
+ if last.tool_call?
252
+ last.destroy
253
+ elsif last.tool_result?
254
+ tool_call_message = last.parent_tool_call.message
255
+ expected_results = tool_call_message.tool_calls.pluck(:id)
256
+ actual_results = tool_call_message.tool_results.pluck(:tool_call_id)
257
+
258
+ if expected_results.sort != actual_results.sort
259
+ tool_call_message.tool_results.each(&:destroy)
260
+ tool_call_message.destroy
261
+ end
262
+ end
263
+ end
264
+
265
+ def replace_persisted_system_instructions(instructions)
266
+ system_messages = messages.where(role: :system).order(:id).to_a
267
+
268
+ if system_messages.empty?
269
+ messages.create!(role: :system, content: instructions)
270
+ return
271
+ end
272
+
273
+ primary_message = system_messages.shift
274
+ primary_message.update!(content: instructions) if primary_message.content != instructions
275
+ system_messages.each(&:destroy!)
276
+ end
277
+
278
+ def append_instructions?(append:, replace:)
279
+ return append if replace.nil?
280
+
281
+ append || (replace == false)
282
+ end
283
+
284
+ def persist_system_instruction(instructions, append:)
285
+ transaction do
286
+ if append
287
+ messages.create!(role: :system, content: instructions)
288
+ else
289
+ replace_persisted_system_instructions(instructions)
290
+ end
291
+ end
292
+ end
293
+
294
+ def order_messages_for_llm(messages)
295
+ system_messages, non_system_messages = messages.partition { |msg| msg.role.to_s == 'system' }
296
+ system_messages + non_system_messages
297
+ end
298
+
299
+ def runtime_instructions
300
+ @runtime_instructions ||= []
301
+ end
302
+
303
+ def store_runtime_instruction(instructions, append:)
304
+ if append
305
+ runtime_instructions << instructions
306
+ else
307
+ @runtime_instructions = [instructions]
308
+ end
309
+ end
310
+
311
+ def reapply_runtime_instructions(chat)
312
+ return if runtime_instructions.empty?
313
+
314
+ first, *rest = runtime_instructions
315
+ chat.with_instructions(first)
316
+ rest.each { |instruction| chat.with_instructions(instruction, append: true) }
317
+ end
318
+
319
+ def setup_persistence_callbacks
320
+ return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
321
+
322
+ @chat.on_new_message { persist_new_message }
323
+ @chat.on_end_message { |msg| persist_message_completion(msg) }
324
+
325
+ @chat.instance_variable_set(:@_persistence_callbacks_setup, true)
326
+ @chat
327
+ end
328
+
329
+ def persist_new_message
330
+ @message = messages.create!(role: :assistant, content: '')
331
+ end
332
+
333
+ def persist_message_completion(message)
334
+ return unless message
335
+
336
+ tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id
337
+
338
+ transaction do
339
+ content, attachments_to_persist = prepare_content_for_storage(message.content)
340
+
341
+ @message.update!(
342
+ role: message.role,
343
+ content: content,
344
+ model_id: message.model_id,
345
+ input_tokens: message.input_tokens,
346
+ output_tokens: message.output_tokens
347
+ )
348
+ @message.write_attribute(@message.class.tool_call_foreign_key, tool_call_id) if tool_call_id
349
+ @message.save!
350
+
351
+ persist_content(@message, attachments_to_persist) if attachments_to_persist
352
+ persist_tool_calls(message.tool_calls) if message.tool_calls.present?
353
+ end
354
+ end
355
+
356
+ def persist_tool_calls(tool_calls, message_record: @message)
357
+ tool_call_assoc = message_record.respond_to?(:tool_calls) ? message_record.tool_calls : nil
358
+ return unless tool_call_assoc
359
+
360
+ supports_thought_signature = tool_call_assoc.klass.column_names.include?('thought_signature')
361
+
362
+ tool_calls.each_value do |tool_call|
363
+ attributes = tool_call.to_h
364
+ attributes.delete(:thought_signature) unless supports_thought_signature
365
+ attributes[:tool_call_id] = attributes.delete(:id)
366
+ tool_call_assoc.create!(**attributes)
367
+ end
368
+ end
369
+
370
+ def find_tool_call_id(tool_call_id)
371
+ self.class.tool_call_class.constantize.find_by(tool_call_id: tool_call_id)&.id
372
+ end
373
+
374
+ def persist_content(message_record, attachments)
375
+ return unless message_record.respond_to?(:attachments)
376
+
377
+ attachables = prepare_for_active_storage(attachments)
378
+ message_record.attachments.attach(attachables) if attachables.any?
379
+ end
380
+
381
+ def prepare_for_active_storage(attachments)
382
+ Utils.to_safe_array(attachments).filter_map do |attachment|
383
+ case attachment
384
+ when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob
385
+ attachment
386
+ when ActiveStorage::Attached::One, ActiveStorage::Attached::Many
387
+ attachment.blobs
388
+ when Hash
389
+ attachment.values.map { |v| prepare_for_active_storage(v) }
390
+ else
391
+ convert_to_active_storage_format(attachment)
392
+ end
393
+ end.flatten.compact
394
+ end
395
+
396
+ def convert_to_active_storage_format(source)
397
+ return if source.blank?
398
+
399
+ attachment = source.is_a?(LexLLM::Attachment) ? source : LexLLM::Attachment.new(source)
400
+
401
+ {
402
+ io: StringIO.new(attachment.content),
403
+ filename: attachment.filename,
404
+ content_type: attachment.mime_type
405
+ }
406
+ rescue StandardError => e
407
+ LexLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
408
+ nil
409
+ end
410
+
411
+ def build_content(message, attachments)
412
+ return message if content_like?(message)
413
+
414
+ LexLLM::Content.new(message, attachments)
415
+ end
416
+
417
+ def content_like?(object)
418
+ object.is_a?(LexLLM::Content) || object.is_a?(LexLLM::Content::Raw)
419
+ end
420
+
421
+ def prepare_content_for_storage(content)
422
+ attachments = nil
423
+
424
+ if content.is_a?(LexLLM::Content)
425
+ attachments = content.attachments if content.attachments.any?
426
+ [content.text, attachments]
427
+ elsif content.is_a?(Hash) || content.is_a?(Array)
428
+ [content.to_json, attachments]
429
+ else
430
+ [content, attachments]
431
+ end
432
+ end
433
+ end
434
+
435
+ # Methods mixed into message models.
436
+ module MessageLegacyMethods
437
+ extend ActiveSupport::Concern
438
+
439
+ class_methods do
440
+ attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key
441
+ end
442
+
443
+ def to_llm
444
+ LexLLM::Message.new(
445
+ role: role.to_sym,
446
+ content: extract_content,
447
+ tool_calls: extract_tool_calls,
448
+ tool_call_id: extract_tool_call_id,
449
+ input_tokens: input_tokens,
450
+ output_tokens: output_tokens,
451
+ model_id: model_id
452
+ )
453
+ end
454
+
455
+ private
456
+
457
+ def extract_tool_calls
458
+ tool_calls.to_h do |tool_call|
459
+ [
460
+ tool_call.tool_call_id,
461
+ LexLLM::ToolCall.new(
462
+ id: tool_call.tool_call_id,
463
+ name: tool_call.name,
464
+ arguments: tool_call.arguments,
465
+ thought_signature: tool_call.try(:thought_signature)
466
+ )
467
+ ]
468
+ end
469
+ end
470
+
471
+ def extract_tool_call_id
472
+ parent_tool_call&.tool_call_id
473
+ end
474
+
475
+ def extract_content
476
+ return content unless respond_to?(:attachments) && attachments.attached?
477
+
478
+ LexLLM::Content.new(content).tap do |content_obj|
479
+ @_tempfiles = []
480
+
481
+ attachments.each do |attachment|
482
+ tempfile = download_attachment(attachment)
483
+ content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
484
+ end
485
+ end
486
+ end
487
+
488
+ def download_attachment(attachment)
489
+ ext = File.extname(attachment.filename.to_s)
490
+ basename = File.basename(attachment.filename.to_s, ext)
491
+ tempfile = Tempfile.new([basename, ext])
492
+ tempfile.binmode
493
+
494
+ attachment.download { |chunk| tempfile.write(chunk) }
495
+
496
+ tempfile.flush
497
+ tempfile.rewind
498
+ @_tempfiles << tempfile
499
+ tempfile
500
+ end
501
+ end
502
+ end
503
+ end