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.
Files changed (165) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +12 -1
  4. data/Gemfile +1 -19
  5. data/README.md +25 -26
  6. data/lex-llm.gemspec +2 -2
  7. data/lib/legion/extensions/llm/agent.rb +366 -0
  8. data/lib/legion/extensions/llm/aliases.rb +42 -0
  9. data/lib/legion/extensions/llm/attachment.rb +229 -0
  10. data/lib/legion/extensions/llm/chat.rb +355 -0
  11. data/lib/legion/extensions/llm/chunk.rb +10 -0
  12. data/lib/legion/extensions/llm/configuration.rb +82 -0
  13. data/lib/legion/extensions/llm/connection.rb +134 -0
  14. data/lib/legion/extensions/llm/content.rb +81 -0
  15. data/lib/legion/extensions/llm/context.rb +33 -0
  16. data/lib/legion/extensions/llm/embedding.rb +33 -0
  17. data/lib/legion/extensions/llm/error.rb +116 -0
  18. data/lib/legion/extensions/llm/image.rb +109 -0
  19. data/lib/legion/extensions/llm/message.rb +111 -0
  20. data/lib/legion/extensions/llm/mime_type.rb +75 -0
  21. data/lib/legion/extensions/llm/model/info.rb +117 -0
  22. data/lib/legion/extensions/llm/model/modalities.rb +26 -0
  23. data/lib/legion/extensions/llm/model/pricing.rb +52 -0
  24. data/lib/legion/extensions/llm/model/pricing_category.rb +50 -0
  25. data/lib/legion/extensions/llm/model/pricing_tier.rb +37 -0
  26. data/lib/legion/extensions/llm/model.rb +11 -0
  27. data/lib/legion/extensions/llm/models.rb +514 -0
  28. data/lib/{lex_llm → legion/extensions/llm}/models_schema.json +1 -1
  29. data/lib/legion/extensions/llm/moderation.rb +60 -0
  30. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +276 -0
  31. data/lib/legion/extensions/llm/provider.rb +337 -0
  32. data/lib/legion/extensions/llm/routing/lane_key.rb +57 -0
  33. data/lib/legion/extensions/llm/routing/model_offering.rb +173 -0
  34. data/lib/legion/extensions/llm/routing.rb +11 -0
  35. data/lib/legion/extensions/llm/stream_accumulator.rb +209 -0
  36. data/lib/legion/extensions/llm/streaming.rb +181 -0
  37. data/lib/legion/extensions/llm/thinking.rb +53 -0
  38. data/lib/legion/extensions/llm/tokens.rb +51 -0
  39. data/lib/legion/extensions/llm/tool.rb +258 -0
  40. data/lib/legion/extensions/llm/tool_call.rb +29 -0
  41. data/lib/legion/extensions/llm/transcription.rb +39 -0
  42. data/lib/legion/extensions/llm/utils.rb +95 -0
  43. data/lib/legion/extensions/llm/version.rb +9 -0
  44. data/lib/legion/extensions/llm.rb +85 -6
  45. metadata +40 -122
  46. data/lib/generators/lex_llm/agent/agent_generator.rb +0 -36
  47. data/lib/generators/lex_llm/agent/templates/agent.rb.tt +0 -6
  48. data/lib/generators/lex_llm/agent/templates/instructions.txt.erb.tt +0 -0
  49. data/lib/generators/lex_llm/chat_ui/chat_ui_generator.rb +0 -256
  50. data/lib/generators/lex_llm/chat_ui/templates/controllers/chats_controller.rb.tt +0 -38
  51. data/lib/generators/lex_llm/chat_ui/templates/controllers/messages_controller.rb.tt +0 -21
  52. data/lib/generators/lex_llm/chat_ui/templates/controllers/models_controller.rb.tt +0 -14
  53. data/lib/generators/lex_llm/chat_ui/templates/helpers/messages_helper.rb.tt +0 -25
  54. data/lib/generators/lex_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +0 -12
  55. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +0 -16
  56. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +0 -31
  57. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +0 -31
  58. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +0 -9
  59. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +0 -27
  60. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +0 -14
  61. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +0 -1
  62. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +0 -13
  63. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +0 -23
  64. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +0 -10
  65. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +0 -2
  66. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +0 -4
  67. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +0 -14
  68. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +0 -13
  69. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +0 -21
  70. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +0 -17
  71. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +0 -40
  72. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +0 -27
  73. data/lib/generators/lex_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +0 -16
  74. data/lib/generators/lex_llm/chat_ui/templates/views/chats/_form.html.erb.tt +0 -29
  75. data/lib/generators/lex_llm/chat_ui/templates/views/chats/index.html.erb.tt +0 -28
  76. data/lib/generators/lex_llm/chat_ui/templates/views/chats/new.html.erb.tt +0 -11
  77. data/lib/generators/lex_llm/chat_ui/templates/views/chats/show.html.erb.tt +0 -25
  78. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +0 -9
  79. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_content.html.erb.tt +0 -1
  80. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_error.html.erb.tt +0 -8
  81. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_form.html.erb.tt +0 -21
  82. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_system.html.erb.tt +0 -6
  83. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +0 -2
  84. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +0 -4
  85. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_user.html.erb.tt +0 -9
  86. data/lib/generators/lex_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +0 -7
  87. data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +0 -8
  88. data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +0 -16
  89. data/lib/generators/lex_llm/chat_ui/templates/views/models/_model.html.erb.tt +0 -15
  90. data/lib/generators/lex_llm/chat_ui/templates/views/models/index.html.erb.tt +0 -38
  91. data/lib/generators/lex_llm/chat_ui/templates/views/models/show.html.erb.tt +0 -17
  92. data/lib/generators/lex_llm/generator_helpers.rb +0 -214
  93. data/lib/generators/lex_llm/install/install_generator.rb +0 -109
  94. data/lib/generators/lex_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +0 -9
  95. data/lib/generators/lex_llm/install/templates/chat_model.rb.tt +0 -3
  96. data/lib/generators/lex_llm/install/templates/create_chats_migration.rb.tt +0 -7
  97. data/lib/generators/lex_llm/install/templates/create_messages_migration.rb.tt +0 -19
  98. data/lib/generators/lex_llm/install/templates/create_models_migration.rb.tt +0 -39
  99. data/lib/generators/lex_llm/install/templates/create_tool_calls_migration.rb.tt +0 -21
  100. data/lib/generators/lex_llm/install/templates/initializer.rb.tt +0 -20
  101. data/lib/generators/lex_llm/install/templates/message_model.rb.tt +0 -4
  102. data/lib/generators/lex_llm/install/templates/model_model.rb.tt +0 -3
  103. data/lib/generators/lex_llm/install/templates/tool_call_model.rb.tt +0 -3
  104. data/lib/generators/lex_llm/schema/schema_generator.rb +0 -26
  105. data/lib/generators/lex_llm/schema/templates/schema.rb.tt +0 -2
  106. data/lib/generators/lex_llm/tool/templates/tool.rb.tt +0 -9
  107. data/lib/generators/lex_llm/tool/templates/tool_call.html.erb.tt +0 -13
  108. data/lib/generators/lex_llm/tool/templates/tool_result.html.erb.tt +0 -13
  109. data/lib/generators/lex_llm/tool/tool_generator.rb +0 -96
  110. data/lib/generators/lex_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +0 -19
  111. data/lib/generators/lex_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +0 -50
  112. data/lib/generators/lex_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +0 -7
  113. data/lib/generators/lex_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +0 -49
  114. data/lib/generators/lex_llm/upgrade_to_v1_7/templates/migration.rb.tt +0 -145
  115. data/lib/generators/lex_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +0 -122
  116. data/lib/generators/lex_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +0 -15
  117. data/lib/generators/lex_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +0 -49
  118. data/lib/lex_llm/active_record/acts_as.rb +0 -180
  119. data/lib/lex_llm/active_record/acts_as_legacy.rb +0 -503
  120. data/lib/lex_llm/active_record/chat_methods.rb +0 -468
  121. data/lib/lex_llm/active_record/message_methods.rb +0 -131
  122. data/lib/lex_llm/active_record/model_methods.rb +0 -76
  123. data/lib/lex_llm/active_record/payload_helpers.rb +0 -26
  124. data/lib/lex_llm/active_record/tool_call_methods.rb +0 -15
  125. data/lib/lex_llm/agent.rb +0 -365
  126. data/lib/lex_llm/aliases.rb +0 -38
  127. data/lib/lex_llm/attachment.rb +0 -223
  128. data/lib/lex_llm/chat.rb +0 -351
  129. data/lib/lex_llm/chunk.rb +0 -6
  130. data/lib/lex_llm/configuration.rb +0 -81
  131. data/lib/lex_llm/connection.rb +0 -130
  132. data/lib/lex_llm/content.rb +0 -77
  133. data/lib/lex_llm/context.rb +0 -29
  134. data/lib/lex_llm/embedding.rb +0 -29
  135. data/lib/lex_llm/error.rb +0 -112
  136. data/lib/lex_llm/image.rb +0 -105
  137. data/lib/lex_llm/message.rb +0 -107
  138. data/lib/lex_llm/mime_type.rb +0 -71
  139. data/lib/lex_llm/model/info.rb +0 -113
  140. data/lib/lex_llm/model/modalities.rb +0 -22
  141. data/lib/lex_llm/model/pricing.rb +0 -48
  142. data/lib/lex_llm/model/pricing_category.rb +0 -46
  143. data/lib/lex_llm/model/pricing_tier.rb +0 -33
  144. data/lib/lex_llm/model.rb +0 -7
  145. data/lib/lex_llm/models.rb +0 -506
  146. data/lib/lex_llm/moderation.rb +0 -56
  147. data/lib/lex_llm/provider/open_ai_compatible.rb +0 -219
  148. data/lib/lex_llm/provider.rb +0 -278
  149. data/lib/lex_llm/railtie.rb +0 -35
  150. data/lib/lex_llm/routing/lane_key.rb +0 -51
  151. data/lib/lex_llm/routing/model_offering.rb +0 -169
  152. data/lib/lex_llm/routing.rb +0 -7
  153. data/lib/lex_llm/stream_accumulator.rb +0 -203
  154. data/lib/lex_llm/streaming.rb +0 -175
  155. data/lib/lex_llm/thinking.rb +0 -49
  156. data/lib/lex_llm/tokens.rb +0 -47
  157. data/lib/lex_llm/tool.rb +0 -254
  158. data/lib/lex_llm/tool_call.rb +0 -25
  159. data/lib/lex_llm/transcription.rb +0 -35
  160. data/lib/lex_llm/utils.rb +0 -91
  161. data/lib/lex_llm/version.rb +0 -5
  162. data/lib/lex_llm.rb +0 -96
  163. data/lib/tasks/lex_llm.rake +0 -23
  164. /data/lib/{lex_llm → legion/extensions/llm}/aliases.json +0 -0
  165. /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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ class Chunk < Message
7
+ end
8
+ end
9
+ end
10
+ 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