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,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ module ActiveRecord
5
+ # Methods mixed into chat models.
6
+ module ChatMethods
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ before_save :resolve_model_from_strings
11
+ end
12
+
13
+ attr_accessor :assume_model_exists, :context
14
+
15
+ def model=(value)
16
+ @model_string = value if value.is_a?(String)
17
+ return if value.is_a?(String)
18
+
19
+ if self.class.model_association_name == :model
20
+ super
21
+ else
22
+ self.model_association = value
23
+ end
24
+ end
25
+
26
+ def model_id=(value)
27
+ @model_string = value
28
+ end
29
+
30
+ def model_id
31
+ model_association&.model_id
32
+ end
33
+
34
+ def provider=(value)
35
+ @provider_string = value
36
+ end
37
+
38
+ def provider
39
+ model_association&.provider
40
+ end
41
+
42
+ private
43
+
44
+ def resolve_model_from_strings # rubocop:disable Metrics/PerceivedComplexity
45
+ config = context&.config || LexLLM.config
46
+ @model_string ||= config.default_model unless model_association
47
+ return unless @model_string
48
+
49
+ model_info, _provider = Models.resolve(
50
+ @model_string,
51
+ provider: @provider_string,
52
+ assume_exists: assume_model_exists || false,
53
+ config: config
54
+ )
55
+
56
+ model_class = self.class.model_class.constantize
57
+ model_record = model_class.find_or_create_by!(
58
+ model_id: model_info.id,
59
+ provider: model_info.provider
60
+ ) do |m|
61
+ m.name = model_info.name || model_info.id
62
+ m.family = model_info.family
63
+ m.context_window = model_info.context_window
64
+ m.max_output_tokens = model_info.max_output_tokens
65
+ m.capabilities = model_info.capabilities || []
66
+ m.modalities = model_info.modalities || {}
67
+ m.pricing = model_info.pricing || {}
68
+ m.metadata = model_info.metadata || {}
69
+ end
70
+
71
+ self.model_association = model_record
72
+ @model_string = nil
73
+ @provider_string = nil
74
+ end
75
+
76
+ public
77
+
78
+ def to_llm
79
+ model_record = model_association
80
+ @chat ||= (context || LexLLM).chat(
81
+ model: model_record.model_id,
82
+ provider: model_record.provider.to_sym,
83
+ assume_model_exists: assume_model_exists || false
84
+ )
85
+ @chat.reset_messages!
86
+
87
+ ordered_messages = order_messages_for_llm(messages_association.to_a)
88
+ ordered_messages.each do |msg|
89
+ @chat.add_message(msg.to_llm)
90
+ end
91
+ reapply_runtime_instructions(@chat)
92
+
93
+ setup_persistence_callbacks
94
+ end
95
+
96
+ def with_instructions(instructions, append: false, replace: nil)
97
+ append = append_instructions?(append:, replace:)
98
+ persist_system_instruction(instructions, append:)
99
+
100
+ to_llm.with_instructions(instructions, append:, replace:)
101
+ self
102
+ end
103
+
104
+ def with_runtime_instructions(instructions, append: false, replace: nil)
105
+ append = append_instructions?(append:, replace:)
106
+ store_runtime_instruction(instructions, append:)
107
+
108
+ to_llm.with_instructions(instructions, append:, replace:)
109
+ self
110
+ end
111
+
112
+ def with_tool(...)
113
+ to_llm.with_tool(...)
114
+ self
115
+ end
116
+
117
+ def with_tools(...)
118
+ to_llm.with_tools(...)
119
+ self
120
+ end
121
+
122
+ def with_model(model_name, provider: nil, assume_exists: false)
123
+ self.model = model_name
124
+ self.provider = provider if provider
125
+ self.assume_model_exists = assume_exists
126
+ resolve_model_from_strings
127
+ save!
128
+ to_llm.with_model(model_association.model_id, provider: model_association.provider.to_sym, assume_exists:)
129
+ self
130
+ end
131
+
132
+ def with_temperature(...)
133
+ to_llm.with_temperature(...)
134
+ self
135
+ end
136
+
137
+ def with_thinking(...)
138
+ to_llm.with_thinking(...)
139
+ self
140
+ end
141
+
142
+ def with_params(...)
143
+ to_llm.with_params(...)
144
+ self
145
+ end
146
+
147
+ def with_headers(...)
148
+ to_llm.with_headers(...)
149
+ self
150
+ end
151
+
152
+ def with_schema(...)
153
+ to_llm.with_schema(...)
154
+ self
155
+ end
156
+
157
+ def on_new_message(&block)
158
+ to_llm
159
+
160
+ existing_callback = @chat.instance_variable_get(:@on)[:new_message]
161
+
162
+ @chat.on_new_message do
163
+ existing_callback&.call
164
+ block&.call
165
+ end
166
+ self
167
+ end
168
+
169
+ def on_end_message(&block)
170
+ to_llm
171
+
172
+ existing_callback = @chat.instance_variable_get(:@on)[:end_message]
173
+
174
+ @chat.on_end_message do |msg|
175
+ existing_callback&.call(msg)
176
+ block&.call(msg)
177
+ end
178
+ self
179
+ end
180
+
181
+ def on_tool_call(...)
182
+ to_llm.on_tool_call(...)
183
+ self
184
+ end
185
+
186
+ def on_tool_result(...)
187
+ to_llm.on_tool_result(...)
188
+ self
189
+ end
190
+
191
+ def add_message(message_or_attributes)
192
+ llm_message = message_or_attributes.is_a?(LexLLM::Message) ? message_or_attributes : LexLLM::Message.new(message_or_attributes)
193
+ content_text, attachments, content_raw = prepare_content_for_storage(llm_message.content)
194
+
195
+ attrs = { role: llm_message.role, content: content_text }
196
+ parent_tool_call_assoc = messages_association.klass.reflect_on_association(:parent_tool_call)
197
+ if parent_tool_call_assoc && llm_message.tool_call_id
198
+ tool_call_id = find_tool_call_id(llm_message.tool_call_id)
199
+ attrs[parent_tool_call_assoc.foreign_key] = tool_call_id if tool_call_id
200
+ end
201
+
202
+ message_record = messages_association.create!(attrs)
203
+ message_record.update!(content_raw:) if message_record.respond_to?(:content_raw=)
204
+
205
+ persist_content(message_record, attachments) if attachments.present?
206
+ persist_tool_calls(llm_message.tool_calls, message_record:) if llm_message.tool_calls.present?
207
+
208
+ message_record
209
+ end
210
+
211
+ def create_user_message(content, with: nil)
212
+ add_message(role: :user, content: build_content(content, with))
213
+ end
214
+
215
+ def ask(message = nil, with: nil, &)
216
+ add_message(role: :user, content: build_content(message, with))
217
+ complete(&)
218
+ end
219
+
220
+ alias say ask
221
+
222
+ def complete(...)
223
+ to_llm.complete(...)
224
+ rescue LexLLM::Error => e
225
+ cleanup_failed_messages if @message&.persisted? && @message.content.blank?
226
+ cleanup_orphaned_tool_results
227
+ raise e
228
+ end
229
+
230
+ private
231
+
232
+ def cleanup_failed_messages
233
+ LexLLM.logger.warn "LexLLM: API call failed, destroying message: #{@message.id}"
234
+ @message.destroy
235
+ end
236
+
237
+ def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity
238
+ messages_association.reload
239
+ last = messages_association.order(:id).last
240
+
241
+ return unless last&.tool_call? || last&.tool_result?
242
+
243
+ if last.tool_call?
244
+ last.destroy
245
+ elsif last.tool_result?
246
+ tool_call_message = last.parent_tool_call.message_association
247
+ expected_results = tool_call_message.tool_calls_association.pluck(:id)
248
+ fk_column = tool_call_message.class.reflections['tool_results'].foreign_key
249
+ actual_results = tool_call_message.tool_results.pluck(fk_column)
250
+
251
+ if expected_results.sort != actual_results.sort
252
+ tool_call_message.tool_results.each(&:destroy)
253
+ tool_call_message.destroy
254
+ end
255
+ end
256
+ end
257
+
258
+ def setup_persistence_callbacks
259
+ return @chat if @chat.instance_variable_get(:@_persistence_callbacks_setup)
260
+
261
+ @chat.on_new_message { persist_new_message }
262
+ @chat.on_end_message { |msg| persist_message_completion(msg) }
263
+
264
+ @chat.instance_variable_set(:@_persistence_callbacks_setup, true)
265
+ @chat
266
+ end
267
+
268
+ def replace_persisted_system_instructions(instructions)
269
+ system_messages = messages_association.where(role: :system).order(:id).to_a
270
+
271
+ if system_messages.empty?
272
+ messages_association.create!(role: :system, content: instructions)
273
+ return
274
+ end
275
+
276
+ primary_message = system_messages.shift
277
+ primary_message.update!(content: instructions) if primary_message.content != instructions
278
+ system_messages.each(&:destroy!)
279
+ end
280
+
281
+ def append_instructions?(append:, replace:)
282
+ return append if replace.nil?
283
+
284
+ append || (replace == false)
285
+ end
286
+
287
+ def persist_system_instruction(instructions, append:)
288
+ transaction do
289
+ if append
290
+ messages_association.create!(role: :system, content: instructions)
291
+ else
292
+ replace_persisted_system_instructions(instructions)
293
+ end
294
+ end
295
+ end
296
+
297
+ def order_messages_for_llm(messages)
298
+ system_messages, non_system_messages = messages.partition { |msg| msg.role.to_s == 'system' }
299
+ system_messages + non_system_messages
300
+ end
301
+
302
+ def runtime_instructions
303
+ @runtime_instructions ||= []
304
+ end
305
+
306
+ def store_runtime_instruction(instructions, append:)
307
+ if append
308
+ runtime_instructions << instructions
309
+ else
310
+ @runtime_instructions = [instructions]
311
+ end
312
+ end
313
+
314
+ def reapply_runtime_instructions(chat)
315
+ return if runtime_instructions.empty?
316
+
317
+ first, *rest = runtime_instructions
318
+ chat.with_instructions(first)
319
+ rest.each { |instruction| chat.with_instructions(instruction, append: true) }
320
+ end
321
+
322
+ def persist_new_message
323
+ @message = messages_association.create!(role: :assistant, content: '')
324
+ end
325
+
326
+ # rubocop:disable Metrics/PerceivedComplexity
327
+ def persist_message_completion(message)
328
+ return unless message
329
+
330
+ tool_call_id = find_tool_call_id(message.tool_call_id) if message.tool_call_id
331
+
332
+ transaction do
333
+ content_text, attachments_to_persist, content_raw = prepare_content_for_storage(message.content)
334
+
335
+ attrs = {
336
+ role: message.role,
337
+ content: content_text,
338
+ input_tokens: message.input_tokens,
339
+ output_tokens: message.output_tokens
340
+ }
341
+ attrs[:cached_tokens] = message.cached_tokens if @message.has_attribute?(:cached_tokens)
342
+ if @message.has_attribute?(:cache_creation_tokens)
343
+ attrs[:cache_creation_tokens] = message.cache_creation_tokens
344
+ end
345
+ attrs[:thinking_text] = message.thinking&.text if @message.has_attribute?(:thinking_text)
346
+ attrs[:thinking_signature] = message.thinking&.signature if @message.has_attribute?(:thinking_signature)
347
+ attrs[:thinking_tokens] = message.thinking_tokens if @message.has_attribute?(:thinking_tokens)
348
+
349
+ # Add model association dynamically
350
+ attrs[self.class.model_association_name] = model_association
351
+
352
+ if tool_call_id
353
+ parent_tool_call_assoc = @message.class.reflect_on_association(:parent_tool_call)
354
+ attrs[parent_tool_call_assoc.foreign_key] = tool_call_id
355
+ end
356
+
357
+ @message.assign_attributes(attrs)
358
+ @message.content_raw = content_raw if @message.respond_to?(:content_raw=)
359
+ @message.save!
360
+
361
+ persist_content(@message, attachments_to_persist) if attachments_to_persist
362
+ persist_tool_calls(message.tool_calls) if message.tool_calls.present?
363
+ end
364
+ end
365
+ # rubocop:enable Metrics/PerceivedComplexity
366
+
367
+ def persist_tool_calls(tool_calls, message_record: @message)
368
+ tool_call_klass = message_record.tool_calls_association.klass
369
+ supports_thought_signature = tool_call_klass.column_names.include?('thought_signature')
370
+
371
+ tool_calls.each_value do |tool_call|
372
+ attributes = tool_call.to_h
373
+ attributes.delete(:thought_signature) unless supports_thought_signature
374
+ attributes[:tool_call_id] = attributes.delete(:id)
375
+ message_record.tool_calls_association.create!(**attributes)
376
+ end
377
+ end
378
+
379
+ def find_tool_call_id(tool_call_id)
380
+ messages = messages_association
381
+ message_class = messages.klass
382
+ tool_calls_assoc = message_class.tool_calls_association_name
383
+ tool_call_table_name = message_class.reflect_on_association(tool_calls_assoc).table_name
384
+
385
+ message_with_tool_call = messages.joins(tool_calls_assoc)
386
+ .find_by(tool_call_table_name => { tool_call_id: tool_call_id })
387
+ return nil unless message_with_tool_call
388
+
389
+ tool_call = message_with_tool_call.tool_calls_association.find_by(tool_call_id: tool_call_id)
390
+ tool_call&.id
391
+ end
392
+
393
+ def persist_content(message_record, attachments)
394
+ return unless message_record.respond_to?(:attachments)
395
+
396
+ attachables = prepare_for_active_storage(attachments)
397
+ message_record.attachments.attach(attachables) if attachables.any?
398
+ end
399
+
400
+ def prepare_for_active_storage(attachments)
401
+ Utils.to_safe_array(attachments).filter_map do |attachment|
402
+ case attachment
403
+ when ActionDispatch::Http::UploadedFile, ActiveStorage::Blob
404
+ attachment
405
+ when ActiveStorage::Attached::One, ActiveStorage::Attached::Many
406
+ attachment.blobs
407
+ when Hash
408
+ attachment.values.map { |v| prepare_for_active_storage(v) }
409
+ else
410
+ convert_to_active_storage_format(attachment)
411
+ end
412
+ end.flatten.compact
413
+ end
414
+
415
+ def convert_to_active_storage_format(source)
416
+ return if source.blank?
417
+
418
+ attachment = source.is_a?(LexLLM::Attachment) ? source : LexLLM::Attachment.new(source)
419
+
420
+ if attachment.active_storage?
421
+ case attachment.source
422
+ when ActiveStorage::Blob then attachment.source
423
+ when ActiveStorage::Attached::One, ActiveStorage::Attached::Many then attachment.source.blobs
424
+ end
425
+ else
426
+ {
427
+ io: StringIO.new(attachment.content),
428
+ filename: attachment.filename,
429
+ content_type: attachment.mime_type
430
+ }
431
+ end
432
+ rescue StandardError => e
433
+ LexLLM.logger.warn "Failed to process attachment #{source}: #{e.message}"
434
+ nil
435
+ end
436
+
437
+ def build_content(message, attachments)
438
+ return message if content_like?(message)
439
+
440
+ LexLLM::Content.new(message, attachments)
441
+ end
442
+
443
+ def content_like?(object)
444
+ object.is_a?(LexLLM::Content) || object.is_a?(LexLLM::Content::Raw)
445
+ end
446
+
447
+ def prepare_content_for_storage(content)
448
+ attachments = nil
449
+ content_raw = nil
450
+ content_text = content
451
+
452
+ case content
453
+ when LexLLM::Content::Raw
454
+ content_raw = content.value
455
+ content_text = nil
456
+ when LexLLM::Content
457
+ attachments = content.attachments if content.attachments.any?
458
+ content_text = content.text
459
+ when Hash, Array
460
+ content_raw = content
461
+ content_text = nil
462
+ end
463
+
464
+ [content_text, attachments, content_raw]
465
+ end
466
+ end
467
+ end
468
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ module ActiveRecord
5
+ # Methods mixed into message models.
6
+ module MessageMethods
7
+ extend ActiveSupport::Concern
8
+ include PayloadHelpers
9
+
10
+ class_methods do
11
+ attr_reader :chat_class, :tool_call_class, :chat_foreign_key, :tool_call_foreign_key
12
+ end
13
+
14
+ def to_llm
15
+ LexLLM::Message.new(
16
+ role: role.to_sym,
17
+ content: extract_content,
18
+ thinking: thinking,
19
+ tokens: tokens,
20
+ tool_calls: extract_tool_calls,
21
+ tool_call_id: extract_tool_call_id,
22
+ model_id: model_association&.model_id
23
+ )
24
+ end
25
+
26
+ def thinking
27
+ LexLLM::Thinking.build(
28
+ text: thinking_text_value,
29
+ signature: thinking_signature_value
30
+ )
31
+ end
32
+
33
+ def tokens
34
+ LexLLM::Tokens.build(
35
+ input: input_tokens,
36
+ output: output_tokens,
37
+ cached: cached_value,
38
+ cache_creation: cache_creation_value,
39
+ thinking: thinking_tokens_value
40
+ )
41
+ end
42
+
43
+ def to_partial_path
44
+ partial_prefix = self.class.name.underscore.pluralize
45
+ role_partial = if to_llm.tool_call?
46
+ 'tool_calls'
47
+ elsif role.to_s == 'tool'
48
+ 'tool'
49
+ else
50
+ role.to_s.presence || 'assistant'
51
+ end
52
+ "#{partial_prefix}/#{role_partial}"
53
+ end
54
+
55
+ def tool_error_message
56
+ payload_error_message(content)
57
+ end
58
+
59
+ private
60
+
61
+ def thinking_text_value
62
+ has_attribute?(:thinking_text) ? self[:thinking_text] : nil
63
+ end
64
+
65
+ def thinking_signature_value
66
+ has_attribute?(:thinking_signature) ? self[:thinking_signature] : nil
67
+ end
68
+
69
+ def cached_value
70
+ has_attribute?(:cached_tokens) ? self[:cached_tokens] : nil
71
+ end
72
+
73
+ def cache_creation_value
74
+ has_attribute?(:cache_creation_tokens) ? self[:cache_creation_tokens] : nil
75
+ end
76
+
77
+ def thinking_tokens_value
78
+ has_attribute?(:thinking_tokens) ? self[:thinking_tokens] : nil
79
+ end
80
+
81
+ def extract_tool_calls
82
+ tool_calls_association.to_h do |tool_call|
83
+ [
84
+ tool_call.tool_call_id,
85
+ LexLLM::ToolCall.new(
86
+ id: tool_call.tool_call_id,
87
+ name: tool_call.name,
88
+ arguments: tool_call.arguments,
89
+ thought_signature: tool_call.try(:thought_signature)
90
+ )
91
+ ]
92
+ end
93
+ end
94
+
95
+ def extract_tool_call_id
96
+ parent_tool_call&.tool_call_id
97
+ end
98
+
99
+ def extract_content
100
+ return LexLLM::Content::Raw.new(content_raw) if has_attribute?(:content_raw) && content_raw.present?
101
+
102
+ content_value = self[:content]
103
+
104
+ return content_value unless respond_to?(:attachments) && attachments.attached?
105
+
106
+ LexLLM::Content.new(content_value).tap do |content_obj|
107
+ @_tempfiles = []
108
+
109
+ attachments.each do |attachment|
110
+ tempfile = download_attachment(attachment)
111
+ content_obj.add_attachment(tempfile, filename: attachment.filename.to_s)
112
+ end
113
+ end
114
+ end
115
+
116
+ def download_attachment(attachment)
117
+ ext = File.extname(attachment.filename.to_s)
118
+ basename = File.basename(attachment.filename.to_s, ext)
119
+ tempfile = Tempfile.new([basename, ext])
120
+ tempfile.binmode
121
+
122
+ attachment.download { |chunk| tempfile.write(chunk) }
123
+
124
+ tempfile.flush
125
+ tempfile.rewind
126
+ @_tempfiles << tempfile
127
+ tempfile
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ module ActiveRecord
5
+ # Methods mixed into model registry models.
6
+ module ModelMethods
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do # rubocop:disable Metrics/BlockLength
10
+ def refresh!
11
+ LexLLM.models.refresh!
12
+
13
+ save_to_database
14
+ end
15
+
16
+ def save_to_database
17
+ transaction do
18
+ LexLLM.models.all.each do |model_info|
19
+ model = find_or_initialize_by(
20
+ model_id: model_info.id,
21
+ provider: model_info.provider
22
+ )
23
+ model.update!(from_llm_attributes(model_info))
24
+ end
25
+ end
26
+ end
27
+
28
+ def from_llm(model_info)
29
+ new(from_llm_attributes(model_info))
30
+ end
31
+
32
+ private
33
+
34
+ def from_llm_attributes(model_info)
35
+ {
36
+ model_id: model_info.id,
37
+ name: model_info.name,
38
+ provider: model_info.provider,
39
+ family: model_info.family,
40
+ model_created_at: model_info.created_at,
41
+ context_window: model_info.context_window,
42
+ max_output_tokens: model_info.max_output_tokens,
43
+ knowledge_cutoff: model_info.knowledge_cutoff,
44
+ modalities: model_info.modalities.to_h,
45
+ capabilities: model_info.capabilities,
46
+ pricing: model_info.pricing.to_h,
47
+ metadata: model_info.metadata
48
+ }
49
+ end
50
+ end
51
+
52
+ def to_llm
53
+ LexLLM::Model::Info.new(
54
+ id: model_id,
55
+ name: name,
56
+ provider: provider,
57
+ family: family,
58
+ created_at: model_created_at,
59
+ context_window: context_window,
60
+ max_output_tokens: max_output_tokens,
61
+ knowledge_cutoff: knowledge_cutoff,
62
+ modalities: modalities&.deep_symbolize_keys || {},
63
+ capabilities: capabilities,
64
+ pricing: pricing&.deep_symbolize_keys || {},
65
+ metadata: metadata&.deep_symbolize_keys || {}
66
+ )
67
+ end
68
+
69
+ delegate :supports?, :supports_vision?, :supports_functions?, :type,
70
+ :input_price_per_million, :output_price_per_million,
71
+ :function_calling?, :structured_output?, :batch?,
72
+ :reasoning?, :citations?, :streaming?, :provider_class, :label,
73
+ to: :to_llm
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ module ActiveRecord
5
+ # Shared helpers for parsing serialized payloads on ActiveRecord-backed models.
6
+ module PayloadHelpers
7
+ private
8
+
9
+ def payload_error_message(value)
10
+ payload = parse_payload(value)
11
+ return unless payload.is_a?(Hash)
12
+
13
+ payload['error'] || payload[:error]
14
+ end
15
+
16
+ def parse_payload(value)
17
+ return value if value.is_a?(Hash) || value.is_a?(Array)
18
+ return if value.blank?
19
+
20
+ Legion::JSON.parse(value, symbolize_names: false)
21
+ rescue Legion::JSON::ParseError
22
+ nil
23
+ end
24
+ end
25
+ end
26
+ end