lex-llm 0.1.2 → 0.1.4
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 +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +12 -1
- data/Gemfile +1 -19
- data/README.md +25 -26
- data/lex-llm.gemspec +2 -2
- data/lib/legion/extensions/llm/agent.rb +366 -0
- data/lib/legion/extensions/llm/aliases.rb +42 -0
- data/lib/legion/extensions/llm/attachment.rb +229 -0
- data/lib/legion/extensions/llm/chat.rb +355 -0
- data/lib/legion/extensions/llm/chunk.rb +10 -0
- data/lib/legion/extensions/llm/configuration.rb +82 -0
- data/lib/legion/extensions/llm/connection.rb +134 -0
- data/lib/legion/extensions/llm/content.rb +81 -0
- data/lib/legion/extensions/llm/context.rb +33 -0
- data/lib/legion/extensions/llm/embedding.rb +33 -0
- data/lib/legion/extensions/llm/error.rb +116 -0
- data/lib/legion/extensions/llm/image.rb +109 -0
- data/lib/legion/extensions/llm/message.rb +111 -0
- data/lib/legion/extensions/llm/mime_type.rb +75 -0
- data/lib/legion/extensions/llm/model/info.rb +117 -0
- data/lib/legion/extensions/llm/model/modalities.rb +26 -0
- data/lib/legion/extensions/llm/model/pricing.rb +52 -0
- data/lib/legion/extensions/llm/model/pricing_category.rb +50 -0
- data/lib/legion/extensions/llm/model/pricing_tier.rb +37 -0
- data/lib/legion/extensions/llm/model.rb +11 -0
- data/lib/legion/extensions/llm/models.rb +514 -0
- data/lib/{lex_llm → legion/extensions/llm}/models_schema.json +1 -1
- data/lib/legion/extensions/llm/moderation.rb +60 -0
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +276 -0
- data/lib/legion/extensions/llm/provider.rb +337 -0
- data/lib/legion/extensions/llm/routing/lane_key.rb +57 -0
- data/lib/legion/extensions/llm/routing/model_offering.rb +173 -0
- data/lib/legion/extensions/llm/routing.rb +11 -0
- data/lib/legion/extensions/llm/stream_accumulator.rb +209 -0
- data/lib/legion/extensions/llm/streaming.rb +181 -0
- data/lib/legion/extensions/llm/thinking.rb +53 -0
- data/lib/legion/extensions/llm/tokens.rb +51 -0
- data/lib/legion/extensions/llm/tool.rb +258 -0
- data/lib/legion/extensions/llm/tool_call.rb +29 -0
- data/lib/legion/extensions/llm/transcription.rb +39 -0
- data/lib/legion/extensions/llm/utils.rb +95 -0
- data/lib/legion/extensions/llm/version.rb +9 -0
- data/lib/legion/extensions/llm.rb +85 -6
- metadata +40 -122
- data/lib/generators/lex_llm/agent/agent_generator.rb +0 -36
- data/lib/generators/lex_llm/agent/templates/agent.rb.tt +0 -6
- data/lib/generators/lex_llm/agent/templates/instructions.txt.erb.tt +0 -0
- data/lib/generators/lex_llm/chat_ui/chat_ui_generator.rb +0 -256
- data/lib/generators/lex_llm/chat_ui/templates/controllers/chats_controller.rb.tt +0 -38
- data/lib/generators/lex_llm/chat_ui/templates/controllers/messages_controller.rb.tt +0 -21
- data/lib/generators/lex_llm/chat_ui/templates/controllers/models_controller.rb.tt +0 -14
- data/lib/generators/lex_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
- data/lib/generators/lex_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +0 -12
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +0 -16
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +0 -31
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +0 -31
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +0 -9
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +0 -27
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +0 -14
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +0 -1
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +0 -13
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +0 -23
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +0 -10
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +0 -2
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +0 -4
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +0 -14
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +0 -13
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +0 -21
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +0 -17
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +0 -40
- data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +0 -27
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +0 -16
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/_form.html.erb.tt +0 -29
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/index.html.erb.tt +0 -28
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/new.html.erb.tt +0 -11
- data/lib/generators/lex_llm/chat_ui/templates/views/chats/show.html.erb.tt +0 -25
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +0 -9
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_content.html.erb.tt +0 -1
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_error.html.erb.tt +0 -8
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_form.html.erb.tt +0 -21
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_system.html.erb.tt +0 -6
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +0 -2
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +0 -4
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/_user.html.erb.tt +0 -9
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +0 -7
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +0 -8
- data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +0 -16
- data/lib/generators/lex_llm/chat_ui/templates/views/models/_model.html.erb.tt +0 -15
- data/lib/generators/lex_llm/chat_ui/templates/views/models/index.html.erb.tt +0 -38
- data/lib/generators/lex_llm/chat_ui/templates/views/models/show.html.erb.tt +0 -17
- data/lib/generators/lex_llm/generator_helpers.rb +0 -214
- data/lib/generators/lex_llm/install/install_generator.rb +0 -109
- data/lib/generators/lex_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
- data/lib/generators/lex_llm/install/templates/chat_model.rb.tt +0 -3
- data/lib/generators/lex_llm/install/templates/create_chats_migration.rb.tt +0 -7
- data/lib/generators/lex_llm/install/templates/create_messages_migration.rb.tt +0 -19
- data/lib/generators/lex_llm/install/templates/create_models_migration.rb.tt +0 -39
- data/lib/generators/lex_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
- data/lib/generators/lex_llm/install/templates/initializer.rb.tt +0 -20
- data/lib/generators/lex_llm/install/templates/message_model.rb.tt +0 -4
- data/lib/generators/lex_llm/install/templates/model_model.rb.tt +0 -3
- data/lib/generators/lex_llm/install/templates/tool_call_model.rb.tt +0 -3
- data/lib/generators/lex_llm/schema/schema_generator.rb +0 -26
- data/lib/generators/lex_llm/schema/templates/schema.rb.tt +0 -2
- data/lib/generators/lex_llm/tool/templates/tool.rb.tt +0 -9
- data/lib/generators/lex_llm/tool/templates/tool_call.html.erb.tt +0 -13
- data/lib/generators/lex_llm/tool/templates/tool_result.html.erb.tt +0 -13
- data/lib/generators/lex_llm/tool/tool_generator.rb +0 -96
- data/lib/generators/lex_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
- data/lib/generators/lex_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
- data/lib/generators/lex_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
- data/lib/generators/lex_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
- data/lib/generators/lex_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
- data/lib/generators/lex_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
- data/lib/generators/lex_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
- data/lib/generators/lex_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
- data/lib/lex_llm/active_record/acts_as.rb +0 -180
- data/lib/lex_llm/active_record/acts_as_legacy.rb +0 -503
- data/lib/lex_llm/active_record/chat_methods.rb +0 -468
- data/lib/lex_llm/active_record/message_methods.rb +0 -131
- data/lib/lex_llm/active_record/model_methods.rb +0 -76
- data/lib/lex_llm/active_record/payload_helpers.rb +0 -26
- data/lib/lex_llm/active_record/tool_call_methods.rb +0 -15
- data/lib/lex_llm/agent.rb +0 -365
- data/lib/lex_llm/aliases.rb +0 -38
- data/lib/lex_llm/attachment.rb +0 -223
- data/lib/lex_llm/chat.rb +0 -351
- data/lib/lex_llm/chunk.rb +0 -6
- data/lib/lex_llm/configuration.rb +0 -81
- data/lib/lex_llm/connection.rb +0 -130
- data/lib/lex_llm/content.rb +0 -77
- data/lib/lex_llm/context.rb +0 -29
- data/lib/lex_llm/embedding.rb +0 -29
- data/lib/lex_llm/error.rb +0 -112
- data/lib/lex_llm/image.rb +0 -105
- data/lib/lex_llm/message.rb +0 -107
- data/lib/lex_llm/mime_type.rb +0 -71
- data/lib/lex_llm/model/info.rb +0 -113
- data/lib/lex_llm/model/modalities.rb +0 -22
- data/lib/lex_llm/model/pricing.rb +0 -48
- data/lib/lex_llm/model/pricing_category.rb +0 -46
- data/lib/lex_llm/model/pricing_tier.rb +0 -33
- data/lib/lex_llm/model.rb +0 -7
- data/lib/lex_llm/models.rb +0 -506
- data/lib/lex_llm/moderation.rb +0 -56
- data/lib/lex_llm/provider/open_ai_compatible.rb +0 -219
- data/lib/lex_llm/provider.rb +0 -278
- data/lib/lex_llm/railtie.rb +0 -35
- data/lib/lex_llm/routing/lane_key.rb +0 -51
- data/lib/lex_llm/routing/model_offering.rb +0 -169
- data/lib/lex_llm/routing.rb +0 -7
- data/lib/lex_llm/stream_accumulator.rb +0 -203
- data/lib/lex_llm/streaming.rb +0 -175
- data/lib/lex_llm/thinking.rb +0 -49
- data/lib/lex_llm/tokens.rb +0 -47
- data/lib/lex_llm/tool.rb +0 -254
- data/lib/lex_llm/tool_call.rb +0 -25
- data/lib/lex_llm/transcription.rb +0 -35
- data/lib/lex_llm/utils.rb +0 -91
- data/lib/lex_llm/version.rb +0 -5
- data/lib/lex_llm.rb +0 -96
- data/lib/tasks/lex_llm.rake +0 -23
- /data/lib/{lex_llm → legion/extensions/llm}/aliases.json +0 -0
- /data/lib/{lex_llm → legion/extensions/llm}/models.json +0 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Llm
|
|
9
|
+
# A class representing a file attachment.
|
|
10
|
+
class Attachment
|
|
11
|
+
attr_reader :source, :filename, :mime_type
|
|
12
|
+
|
|
13
|
+
def initialize(source, filename: nil)
|
|
14
|
+
@source = source
|
|
15
|
+
@source = source_type_cast
|
|
16
|
+
@filename = filename || source_filename
|
|
17
|
+
|
|
18
|
+
determine_mime_type
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def url?
|
|
22
|
+
@source.is_a?(URI) || (@source.is_a?(String) && @source.match?(%r{^https?://}))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def path?
|
|
26
|
+
@source.is_a?(Pathname) || (@source.is_a?(String) && !url?)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def io_like?
|
|
30
|
+
@source.respond_to?(:read) && !path? && !active_storage?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def active_storage?
|
|
34
|
+
return false unless defined?(ActiveStorage)
|
|
35
|
+
|
|
36
|
+
@source.is_a?(ActiveStorage::Blob) ||
|
|
37
|
+
@source.is_a?(ActiveStorage::Attached::One) ||
|
|
38
|
+
@source.is_a?(ActiveStorage::Attached::Many)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def content
|
|
42
|
+
return @content if defined?(@content) && !@content.nil?
|
|
43
|
+
|
|
44
|
+
if url?
|
|
45
|
+
fetch_content
|
|
46
|
+
elsif path?
|
|
47
|
+
load_content_from_path
|
|
48
|
+
elsif active_storage?
|
|
49
|
+
load_content_from_active_storage
|
|
50
|
+
elsif io_like?
|
|
51
|
+
load_content_from_io
|
|
52
|
+
else
|
|
53
|
+
Legion::Extensions::Llm.logger.warn(
|
|
54
|
+
"Source is neither a URL, path, ActiveStorage, nor IO-like: #{@source.class}"
|
|
55
|
+
)
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@content
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def encoded
|
|
63
|
+
Base64.strict_encode64(content)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def save(path)
|
|
67
|
+
return unless io_like?
|
|
68
|
+
|
|
69
|
+
File.open(path, 'w') do |f|
|
|
70
|
+
f.puts(@source.read)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def for_llm
|
|
75
|
+
case type
|
|
76
|
+
when :text
|
|
77
|
+
"<file name='#{filename}' mime_type='#{mime_type}'>#{content}</file>"
|
|
78
|
+
else
|
|
79
|
+
"data:#{mime_type};base64,#{encoded}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def type
|
|
84
|
+
return :image if image?
|
|
85
|
+
return :video if video?
|
|
86
|
+
return :audio if audio?
|
|
87
|
+
return :pdf if pdf?
|
|
88
|
+
return :text if text?
|
|
89
|
+
|
|
90
|
+
:unknown
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def image?
|
|
94
|
+
Legion::Extensions::Llm::MimeType.image? mime_type
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def video?
|
|
98
|
+
Legion::Extensions::Llm::MimeType.video? mime_type
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def audio?
|
|
102
|
+
Legion::Extensions::Llm::MimeType.audio? mime_type
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def format
|
|
106
|
+
case mime_type
|
|
107
|
+
when 'audio/mpeg'
|
|
108
|
+
'mp3'
|
|
109
|
+
when 'audio/wav', 'audio/wave', 'audio/x-wav'
|
|
110
|
+
'wav'
|
|
111
|
+
else
|
|
112
|
+
mime_type.split('/').last
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def pdf?
|
|
117
|
+
Legion::Extensions::Llm::MimeType.pdf? mime_type
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def text?
|
|
121
|
+
Legion::Extensions::Llm::MimeType.text? mime_type
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def to_h
|
|
125
|
+
{ type: type, source: @source }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def determine_mime_type
|
|
131
|
+
return @mime_type = active_storage_content_type if active_storage? && active_storage_content_type.present?
|
|
132
|
+
|
|
133
|
+
@mime_type = Legion::Extensions::Llm::MimeType.for(url? ? nil : @source, name: @filename)
|
|
134
|
+
@mime_type = Legion::Extensions::Llm::MimeType.for(content) if @mime_type == 'application/octet-stream'
|
|
135
|
+
@mime_type = 'audio/wav' if @mime_type == 'audio/x-wav' # Normalize WAV type
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def fetch_content
|
|
139
|
+
response = Connection.basic.get @source.to_s
|
|
140
|
+
@content = response.body
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def load_content_from_path
|
|
144
|
+
@content = File.binread(@source)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def load_content_from_io
|
|
148
|
+
@source.rewind if @source.respond_to? :rewind
|
|
149
|
+
@content = @source.read
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def load_content_from_active_storage
|
|
153
|
+
return unless defined?(ActiveStorage)
|
|
154
|
+
|
|
155
|
+
@content = case @source
|
|
156
|
+
when ActiveStorage::Blob
|
|
157
|
+
@source.download
|
|
158
|
+
when ActiveStorage::Attached::One
|
|
159
|
+
@source.blob&.download
|
|
160
|
+
when ActiveStorage::Attached::Many
|
|
161
|
+
# For multiple attachments, just take the first one
|
|
162
|
+
# This maintains the single-attachment interface
|
|
163
|
+
@source.blobs.first&.download
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def source_type_cast
|
|
168
|
+
if url?
|
|
169
|
+
URI(@source)
|
|
170
|
+
elsif path?
|
|
171
|
+
Pathname.new(@source)
|
|
172
|
+
else
|
|
173
|
+
@source
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def source_filename
|
|
178
|
+
if url?
|
|
179
|
+
File.basename(@source.path).to_s
|
|
180
|
+
elsif path?
|
|
181
|
+
@source.basename.to_s
|
|
182
|
+
elsif io_like?
|
|
183
|
+
extract_filename_from_io
|
|
184
|
+
elsif active_storage?
|
|
185
|
+
extract_filename_from_active_storage
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def extract_filename_from_io
|
|
190
|
+
if defined?(ActionDispatch::Http::UploadedFile) && @source.is_a?(ActionDispatch::Http::UploadedFile)
|
|
191
|
+
@source.original_filename.to_s
|
|
192
|
+
elsif @source.respond_to?(:path)
|
|
193
|
+
File.basename(@source.path).to_s
|
|
194
|
+
else
|
|
195
|
+
'attachment'
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def extract_filename_from_active_storage # rubocop:disable Metrics/PerceivedComplexity
|
|
200
|
+
return 'attachment' unless defined?(ActiveStorage)
|
|
201
|
+
|
|
202
|
+
case @source
|
|
203
|
+
when ActiveStorage::Blob
|
|
204
|
+
@source.filename.to_s
|
|
205
|
+
when ActiveStorage::Attached::One
|
|
206
|
+
@source.blob&.filename&.to_s || 'attachment'
|
|
207
|
+
when ActiveStorage::Attached::Many
|
|
208
|
+
@source.blobs.first&.filename&.to_s || 'attachment'
|
|
209
|
+
else
|
|
210
|
+
'attachment'
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def active_storage_content_type
|
|
215
|
+
return unless defined?(ActiveStorage)
|
|
216
|
+
|
|
217
|
+
case @source
|
|
218
|
+
when ActiveStorage::Blob
|
|
219
|
+
@source.content_type
|
|
220
|
+
when ActiveStorage::Attached::One
|
|
221
|
+
@source.blob&.content_type
|
|
222
|
+
when ActiveStorage::Attached::Many
|
|
223
|
+
@source.blobs.first&.content_type
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# Represents a conversation with an AI model
|
|
7
|
+
class Chat
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
attr_reader :model, :messages, :tools, :tool_prefs, :params, :headers, :schema
|
|
11
|
+
|
|
12
|
+
def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
|
|
13
|
+
if assume_model_exists && !provider
|
|
14
|
+
raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@context = context
|
|
18
|
+
@config = context&.config || Legion::Extensions::Llm.config
|
|
19
|
+
model_id = model || @config.default_model
|
|
20
|
+
with_model(model_id, provider: provider, assume_exists: assume_model_exists)
|
|
21
|
+
@temperature = nil
|
|
22
|
+
@messages = []
|
|
23
|
+
@tools = {}
|
|
24
|
+
@tool_prefs = { choice: nil, calls: nil }
|
|
25
|
+
@params = {}
|
|
26
|
+
@headers = {}
|
|
27
|
+
@schema = nil
|
|
28
|
+
@thinking = nil
|
|
29
|
+
@on = {
|
|
30
|
+
new_message: nil,
|
|
31
|
+
end_message: nil,
|
|
32
|
+
tool_call: nil,
|
|
33
|
+
tool_result: nil
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ask(message = nil, with: nil, &)
|
|
38
|
+
add_message role: :user, content: build_content(message, with)
|
|
39
|
+
complete(&)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
alias say ask
|
|
43
|
+
|
|
44
|
+
def with_instructions(instructions, append: false, replace: nil)
|
|
45
|
+
append ||= (replace == false) unless replace.nil?
|
|
46
|
+
|
|
47
|
+
if append
|
|
48
|
+
append_system_instruction(instructions)
|
|
49
|
+
else
|
|
50
|
+
replace_system_instruction(instructions)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def with_tool(tool, choice: nil, calls: nil)
|
|
57
|
+
unless tool.nil?
|
|
58
|
+
tool_instance = tool.is_a?(Class) ? tool.new : tool
|
|
59
|
+
@tools[tool_instance.name.to_sym] = tool_instance
|
|
60
|
+
end
|
|
61
|
+
update_tool_options(choice:, calls:)
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def with_tools(*tools, replace: false, choice: nil, calls: nil)
|
|
66
|
+
@tools.clear if replace
|
|
67
|
+
tools.compact.each { |tool| with_tool tool }
|
|
68
|
+
update_tool_options(choice:, calls:)
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def with_model(model_id, provider: nil, assume_exists: false)
|
|
73
|
+
@model, @provider = Models.resolve(model_id, provider:, assume_exists:, config: @config)
|
|
74
|
+
@connection = @provider.connection
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def with_temperature(temperature)
|
|
79
|
+
@temperature = temperature
|
|
80
|
+
self
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def with_thinking(effort: nil, budget: nil)
|
|
84
|
+
raise ArgumentError, 'with_thinking requires :effort or :budget' if effort.nil? && budget.nil?
|
|
85
|
+
|
|
86
|
+
@thinking = Thinking::Config.new(effort: effort, budget: budget)
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def with_context(context)
|
|
91
|
+
@context = context
|
|
92
|
+
@config = context.config
|
|
93
|
+
with_model(@model.id, provider: @provider.slug, assume_exists: true)
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def with_params(**params)
|
|
98
|
+
@params = params
|
|
99
|
+
self
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def with_headers(**headers)
|
|
103
|
+
@headers = headers
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def with_schema(schema)
|
|
108
|
+
schema_instance = schema.is_a?(Class) ? schema.new : schema
|
|
109
|
+
|
|
110
|
+
@schema = normalize_schema_payload(
|
|
111
|
+
schema_instance.respond_to?(:to_json_schema) ? schema_instance.to_json_schema : schema_instance
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def on_new_message(&block)
|
|
118
|
+
@on[:new_message] = block
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def on_end_message(&block)
|
|
123
|
+
@on[:end_message] = block
|
|
124
|
+
self
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def on_tool_call(&block)
|
|
128
|
+
@on[:tool_call] = block
|
|
129
|
+
self
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def on_tool_result(&block)
|
|
133
|
+
@on[:tool_result] = block
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def each(&)
|
|
138
|
+
messages.each(&)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def complete(&) # rubocop:disable Metrics/PerceivedComplexity
|
|
142
|
+
response = @provider.complete(
|
|
143
|
+
messages,
|
|
144
|
+
tools: @tools,
|
|
145
|
+
tool_prefs: @tool_prefs,
|
|
146
|
+
temperature: @temperature,
|
|
147
|
+
model: @model,
|
|
148
|
+
params: @params,
|
|
149
|
+
headers: @headers,
|
|
150
|
+
schema: @schema,
|
|
151
|
+
thinking: @thinking,
|
|
152
|
+
&wrap_streaming_block(&)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
@on[:new_message]&.call unless block_given?
|
|
156
|
+
|
|
157
|
+
if @schema && response.content.is_a?(String) && !response.tool_call?
|
|
158
|
+
begin
|
|
159
|
+
response.content = Legion::JSON.parse(response.content, symbolize_names: false)
|
|
160
|
+
rescue Legion::JSON::ParseError
|
|
161
|
+
# If parsing fails, keep content as string
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
add_message response
|
|
166
|
+
@on[:end_message]&.call(response)
|
|
167
|
+
|
|
168
|
+
if response.tool_call?
|
|
169
|
+
handle_tool_calls(response, &)
|
|
170
|
+
else
|
|
171
|
+
response
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def add_message(message_or_attributes)
|
|
176
|
+
message = message_or_attributes.is_a?(Message) ? message_or_attributes : Message.new(message_or_attributes)
|
|
177
|
+
messages << message
|
|
178
|
+
message
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def reset_messages!
|
|
182
|
+
@messages.clear
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def instance_variables
|
|
186
|
+
super - %i[@connection @config]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def normalize_schema_payload(raw_schema)
|
|
192
|
+
return nil if raw_schema.nil?
|
|
193
|
+
return raw_schema unless raw_schema.is_a?(Hash)
|
|
194
|
+
|
|
195
|
+
schema = Legion::Extensions::Llm::Utils.deep_symbolize_keys(raw_schema)
|
|
196
|
+
schema_def = extract_schema_definition(schema)
|
|
197
|
+
strict = extract_schema_strict(schema, schema_def)
|
|
198
|
+
build_schema_payload(schema, schema_def, strict)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def extract_schema_definition(schema)
|
|
202
|
+
Legion::Extensions::Llm::Utils.deep_dup(schema[:schema] || schema)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def extract_schema_strict(schema, schema_def)
|
|
206
|
+
return schema[:strict] if schema.key?(:strict)
|
|
207
|
+
return schema_def.delete(:strict) if schema_def.is_a?(Hash)
|
|
208
|
+
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def build_schema_payload(schema, schema_def, strict)
|
|
213
|
+
{
|
|
214
|
+
name: sanitize_schema_name(schema[:name] || 'response'),
|
|
215
|
+
schema: schema_def,
|
|
216
|
+
strict: strict.nil? || strict,
|
|
217
|
+
description: schema[:description]
|
|
218
|
+
}.compact
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def sanitize_schema_name(name)
|
|
222
|
+
sanitized = name.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
|
|
223
|
+
sanitized.empty? ? 'response' : sanitized
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def wrap_streaming_block(&block)
|
|
227
|
+
return nil unless block_given?
|
|
228
|
+
|
|
229
|
+
@on[:new_message]&.call
|
|
230
|
+
|
|
231
|
+
proc do |chunk|
|
|
232
|
+
block.call chunk
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def handle_tool_calls(response, &) # rubocop:disable Metrics/PerceivedComplexity
|
|
237
|
+
halt_result = nil
|
|
238
|
+
|
|
239
|
+
response.tool_calls.each_value do |tool_call|
|
|
240
|
+
@on[:new_message]&.call
|
|
241
|
+
@on[:tool_call]&.call(tool_call)
|
|
242
|
+
result = execute_tool tool_call
|
|
243
|
+
@on[:tool_result]&.call(result)
|
|
244
|
+
tool_payload = result.is_a?(Tool::Halt) ? result.content : result
|
|
245
|
+
content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
|
|
246
|
+
message = add_message role: :tool, content:, tool_call_id: tool_call.id
|
|
247
|
+
@on[:end_message]&.call(message)
|
|
248
|
+
|
|
249
|
+
halt_result = result if result.is_a?(Tool::Halt)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
reset_tool_choice if forced_tool_choice?
|
|
253
|
+
halt_result || complete(&)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def execute_tool(tool_call)
|
|
257
|
+
tool = tools[tool_call.name.to_sym]
|
|
258
|
+
if tool.nil?
|
|
259
|
+
return {
|
|
260
|
+
error: "Model tried to call unavailable tool `#{tool_call.name}`. " \
|
|
261
|
+
"Available tools: #{tools.keys.to_json}."
|
|
262
|
+
}
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
args = tool_call.arguments
|
|
266
|
+
tool.call(args)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def update_tool_options(choice:, calls:)
|
|
270
|
+
unless choice.nil?
|
|
271
|
+
normalized_choice = normalize_tool_choice(choice)
|
|
272
|
+
valid_tool_choices = %i[auto none required] + tools.keys
|
|
273
|
+
unless valid_tool_choices.include?(normalized_choice)
|
|
274
|
+
raise InvalidToolChoiceError,
|
|
275
|
+
"Invalid tool choice: #{choice}. Valid choices are: #{valid_tool_choices.join(', ')}"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
@tool_prefs[:choice] = normalized_choice
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
@tool_prefs[:calls] = normalize_calls(calls) unless calls.nil?
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def normalize_calls(calls)
|
|
285
|
+
case calls
|
|
286
|
+
when :many, 'many'
|
|
287
|
+
:many
|
|
288
|
+
when :one, 'one', 1
|
|
289
|
+
:one
|
|
290
|
+
else
|
|
291
|
+
raise ArgumentError, "Invalid calls value: #{calls.inspect}. Valid values are: :many, :one, or 1"
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def normalize_tool_choice(choice)
|
|
296
|
+
return choice.to_sym if choice.is_a?(String) || choice.is_a?(Symbol)
|
|
297
|
+
return tool_name_for_choice_class(choice) if choice.is_a?(Class)
|
|
298
|
+
|
|
299
|
+
choice.respond_to?(:name) ? choice.name.to_sym : choice.to_sym
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def tool_name_for_choice_class(tool_class)
|
|
303
|
+
matched_tool_name = tools.find { |_name, tool| tool.is_a?(tool_class) }&.first
|
|
304
|
+
return matched_tool_name if matched_tool_name
|
|
305
|
+
|
|
306
|
+
classify_tool_name(tool_class.name)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def classify_tool_name(class_name)
|
|
310
|
+
class_name.split('::').last
|
|
311
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
312
|
+
.downcase
|
|
313
|
+
.to_sym
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def forced_tool_choice?
|
|
317
|
+
@tool_prefs[:choice] && !%i[auto none].include?(@tool_prefs[:choice])
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def reset_tool_choice
|
|
321
|
+
@tool_prefs[:choice] = nil
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def build_content(message, attachments)
|
|
325
|
+
return message if content_like?(message)
|
|
326
|
+
|
|
327
|
+
Content.new(message, attachments)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def content_like?(object)
|
|
331
|
+
object.is_a?(Content) || object.is_a?(Content::Raw)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def append_system_instruction(instructions)
|
|
335
|
+
system_messages, non_system_messages = @messages.partition { |msg| msg.role == :system }
|
|
336
|
+
system_messages << Message.new(role: :system, content: instructions)
|
|
337
|
+
@messages = system_messages + non_system_messages
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def replace_system_instruction(instructions)
|
|
341
|
+
system_messages, non_system_messages = @messages.partition { |msg| msg.role == :system }
|
|
342
|
+
|
|
343
|
+
if system_messages.empty?
|
|
344
|
+
system_messages = [Message.new(role: :system, content: instructions)]
|
|
345
|
+
else
|
|
346
|
+
system_messages.first.content = instructions
|
|
347
|
+
system_messages = [system_messages.first]
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
@messages = system_messages + non_system_messages
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# Global configuration for Legion::Extensions::Llm
|
|
7
|
+
class Configuration
|
|
8
|
+
class << self
|
|
9
|
+
# Declare a single configuration option.
|
|
10
|
+
def option(key, default = nil)
|
|
11
|
+
key = key.to_sym
|
|
12
|
+
return if options.include?(key)
|
|
13
|
+
|
|
14
|
+
send(:attr_accessor, key)
|
|
15
|
+
option_keys << key
|
|
16
|
+
defaults[key] = default
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def register_provider_options(options)
|
|
20
|
+
Array(options).each { |key| option(key, nil) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def options
|
|
24
|
+
option_keys.dup
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def option_keys = @option_keys ||= []
|
|
30
|
+
def defaults = @defaults ||= {}
|
|
31
|
+
private :option
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# System-level options are declared here.
|
|
35
|
+
# Provider-specific options are declared in each provider class via
|
|
36
|
+
# `self.configuration_options` and registered through Provider.register.
|
|
37
|
+
option :default_model, nil
|
|
38
|
+
option :default_embedding_model, nil
|
|
39
|
+
option :default_moderation_model, nil
|
|
40
|
+
option :default_image_model, nil
|
|
41
|
+
option :default_transcription_model, nil
|
|
42
|
+
|
|
43
|
+
option :model_registry_file, -> { File.expand_path('models.json', __dir__) }
|
|
44
|
+
|
|
45
|
+
option :request_timeout, 300
|
|
46
|
+
option :max_retries, 3
|
|
47
|
+
option :retry_interval, 0.1
|
|
48
|
+
option :retry_backoff_factor, 2
|
|
49
|
+
option :retry_interval_randomness, 0.5
|
|
50
|
+
option :http_proxy, nil
|
|
51
|
+
|
|
52
|
+
option :logger, nil
|
|
53
|
+
option :log_file, -> { $stdout }
|
|
54
|
+
option :log_level, -> { ENV['LEGION_LLM_DEBUG'] ? Logger::DEBUG : Logger::INFO }
|
|
55
|
+
option :log_stream_debug, -> { ENV['LEGION_LLM_STREAM_DEBUG'] == 'true' }
|
|
56
|
+
option :log_regexp_timeout, -> { Regexp.respond_to?(:timeout) ? (Regexp.timeout || 1.0) : nil }
|
|
57
|
+
|
|
58
|
+
def initialize
|
|
59
|
+
self.class.send(:defaults).each do |key, default|
|
|
60
|
+
value = default.respond_to?(:call) ? instance_exec(&default) : default
|
|
61
|
+
public_send("#{key}=", value)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def instance_variables
|
|
66
|
+
super.reject { |ivar| ivar.to_s.match?(/_id|_key|_secret|_token$/) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def log_regexp_timeout=(value)
|
|
70
|
+
if value.nil?
|
|
71
|
+
@log_regexp_timeout = nil
|
|
72
|
+
elsif Regexp.respond_to?(:timeout)
|
|
73
|
+
@log_regexp_timeout = value
|
|
74
|
+
else
|
|
75
|
+
Legion::Extensions::Llm.logger.warn("log_regexp_timeout is not supported on Ruby #{RUBY_VERSION}")
|
|
76
|
+
@log_regexp_timeout = value
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|