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.
- checksums.yaml +7 -0
- data/.github/CODEOWNERS +7 -0
- data/.github/dependabot.yml +18 -0
- data/.github/workflows/ci.yml +16 -0
- data/.gitignore +19 -0
- data/.rubocop.yml +42 -0
- data/CHANGELOG.md +15 -0
- data/Gemfile +50 -0
- data/LICENSE +21 -0
- data/README.md +279 -0
- data/lex-llm.gemspec +43 -0
- data/lib/generators/lex_llm/agent/agent_generator.rb +36 -0
- data/lib/generators/lex_llm/agent/templates/agent.rb.tt +6 -0
- data/lib/generators/lex_llm/agent/templates/instructions.txt.erb.tt +0 -0
- data/lib/generators/lex_llm/chat_ui/chat_ui_generator.rb +256 -0
- data/lib/generators/lex_llm/chat_ui/templates/controllers/chats_controller.rb.tt +38 -0
- data/lib/generators/lex_llm/chat_ui/templates/controllers/messages_controller.rb.tt +21 -0
- data/lib/generators/lex_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
- data/lib/generators/lex_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
- data/lib/generators/lex_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/index.html.erb.tt +28 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/show.html.erb.tt +25 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +7 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/models/_model.html.erb.tt +15 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/models/index.html.erb.tt +38 -0
- data/lib/generators/lex_llm/chat_ui/templates/views/models/show.html.erb.tt +17 -0
- data/lib/generators/lex_llm/generator_helpers.rb +214 -0
- data/lib/generators/lex_llm/install/install_generator.rb +109 -0
- data/lib/generators/lex_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +9 -0
- data/lib/generators/lex_llm/install/templates/chat_model.rb.tt +3 -0
- data/lib/generators/lex_llm/install/templates/create_chats_migration.rb.tt +7 -0
- data/lib/generators/lex_llm/install/templates/create_messages_migration.rb.tt +19 -0
- data/lib/generators/lex_llm/install/templates/create_models_migration.rb.tt +39 -0
- data/lib/generators/lex_llm/install/templates/create_tool_calls_migration.rb.tt +21 -0
- data/lib/generators/lex_llm/install/templates/initializer.rb.tt +20 -0
- data/lib/generators/lex_llm/install/templates/message_model.rb.tt +4 -0
- data/lib/generators/lex_llm/install/templates/model_model.rb.tt +3 -0
- data/lib/generators/lex_llm/install/templates/tool_call_model.rb.tt +3 -0
- data/lib/generators/lex_llm/schema/schema_generator.rb +26 -0
- data/lib/generators/lex_llm/schema/templates/schema.rb.tt +2 -0
- data/lib/generators/lex_llm/tool/templates/tool.rb.tt +9 -0
- data/lib/generators/lex_llm/tool/templates/tool_call.html.erb.tt +13 -0
- data/lib/generators/lex_llm/tool/templates/tool_result.html.erb.tt +13 -0
- data/lib/generators/lex_llm/tool/tool_generator.rb +96 -0
- data/lib/generators/lex_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +19 -0
- data/lib/generators/lex_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +50 -0
- data/lib/generators/lex_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
- data/lib/generators/lex_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
- data/lib/generators/lex_llm/upgrade_to_v1_7/templates/migration.rb.tt +145 -0
- data/lib/generators/lex_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +122 -0
- data/lib/generators/lex_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +15 -0
- data/lib/generators/lex_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +49 -0
- data/lib/legion/extensions/llm/provider_settings.rb +49 -0
- data/lib/legion/extensions/llm/transport/fleet_lane.rb +70 -0
- data/lib/legion/extensions/llm.rb +50 -0
- data/lib/lex_llm/active_record/acts_as.rb +180 -0
- data/lib/lex_llm/active_record/acts_as_legacy.rb +503 -0
- data/lib/lex_llm/active_record/chat_methods.rb +468 -0
- data/lib/lex_llm/active_record/message_methods.rb +131 -0
- data/lib/lex_llm/active_record/model_methods.rb +76 -0
- data/lib/lex_llm/active_record/payload_helpers.rb +26 -0
- data/lib/lex_llm/active_record/tool_call_methods.rb +15 -0
- data/lib/lex_llm/agent.rb +365 -0
- data/lib/lex_llm/aliases.json +436 -0
- data/lib/lex_llm/aliases.rb +38 -0
- data/lib/lex_llm/attachment.rb +223 -0
- data/lib/lex_llm/chat.rb +351 -0
- data/lib/lex_llm/chunk.rb +6 -0
- data/lib/lex_llm/configuration.rb +81 -0
- data/lib/lex_llm/connection.rb +130 -0
- data/lib/lex_llm/content.rb +77 -0
- data/lib/lex_llm/context.rb +29 -0
- data/lib/lex_llm/embedding.rb +29 -0
- data/lib/lex_llm/error.rb +112 -0
- data/lib/lex_llm/image.rb +105 -0
- data/lib/lex_llm/message.rb +107 -0
- data/lib/lex_llm/mime_type.rb +71 -0
- data/lib/lex_llm/model/info.rb +113 -0
- data/lib/lex_llm/model/modalities.rb +22 -0
- data/lib/lex_llm/model/pricing.rb +48 -0
- data/lib/lex_llm/model/pricing_category.rb +46 -0
- data/lib/lex_llm/model/pricing_tier.rb +33 -0
- data/lib/lex_llm/model.rb +7 -0
- data/lib/lex_llm/models.json +57241 -0
- data/lib/lex_llm/models.rb +506 -0
- data/lib/lex_llm/models_schema.json +168 -0
- data/lib/lex_llm/moderation.rb +56 -0
- data/lib/lex_llm/provider.rb +278 -0
- data/lib/lex_llm/railtie.rb +35 -0
- data/lib/lex_llm/routing/lane_key.rb +51 -0
- data/lib/lex_llm/routing/model_offering.rb +169 -0
- data/lib/lex_llm/routing.rb +7 -0
- data/lib/lex_llm/stream_accumulator.rb +203 -0
- data/lib/lex_llm/streaming.rb +175 -0
- data/lib/lex_llm/thinking.rb +49 -0
- data/lib/lex_llm/tokens.rb +47 -0
- data/lib/lex_llm/tool.rb +254 -0
- data/lib/lex_llm/tool_call.rb +25 -0
- data/lib/lex_llm/transcription.rb +35 -0
- data/lib/lex_llm/utils.rb +91 -0
- data/lib/lex_llm/version.rb +5 -0
- data/lib/lex_llm.rb +95 -0
- data/lib/tasks/lex_llm.rake +23 -0
- 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
|