lex-llm 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. checksums.yaml +7 -0
  2. data/.github/CODEOWNERS +7 -0
  3. data/.github/dependabot.yml +18 -0
  4. data/.github/workflows/ci.yml +16 -0
  5. data/.gitignore +19 -0
  6. data/.rubocop.yml +42 -0
  7. data/CHANGELOG.md +15 -0
  8. data/Gemfile +50 -0
  9. data/LICENSE +21 -0
  10. data/README.md +279 -0
  11. data/lex-llm.gemspec +43 -0
  12. data/lib/generators/lex_llm/agent/agent_generator.rb +36 -0
  13. data/lib/generators/lex_llm/agent/templates/agent.rb.tt +6 -0
  14. data/lib/generators/lex_llm/agent/templates/instructions.txt.erb.tt +0 -0
  15. data/lib/generators/lex_llm/chat_ui/chat_ui_generator.rb +256 -0
  16. data/lib/generators/lex_llm/chat_ui/templates/controllers/chats_controller.rb.tt +38 -0
  17. data/lib/generators/lex_llm/chat_ui/templates/controllers/messages_controller.rb.tt +21 -0
  18. data/lib/generators/lex_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
  19. data/lib/generators/lex_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
  20. data/lib/generators/lex_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
  21. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
  22. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
  23. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
  24. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
  25. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
  26. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
  27. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
  28. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
  29. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
  30. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
  31. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
  32. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
  33. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
  34. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
  35. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
  36. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
  37. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
  38. data/lib/generators/lex_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
  39. data/lib/generators/lex_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
  40. data/lib/generators/lex_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
  41. data/lib/generators/lex_llm/chat_ui/templates/views/chats/index.html.erb.tt +28 -0
  42. data/lib/generators/lex_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
  43. data/lib/generators/lex_llm/chat_ui/templates/views/chats/show.html.erb.tt +25 -0
  44. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
  45. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -0
  46. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
  47. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
  48. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
  49. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
  50. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -0
  51. data/lib/generators/lex_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
  52. data/lib/generators/lex_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +7 -0
  53. data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
  54. data/lib/generators/lex_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
  55. data/lib/generators/lex_llm/chat_ui/templates/views/models/_model.html.erb.tt +15 -0
  56. data/lib/generators/lex_llm/chat_ui/templates/views/models/index.html.erb.tt +38 -0
  57. data/lib/generators/lex_llm/chat_ui/templates/views/models/show.html.erb.tt +17 -0
  58. data/lib/generators/lex_llm/generator_helpers.rb +214 -0
  59. data/lib/generators/lex_llm/install/install_generator.rb +109 -0
  60. data/lib/generators/lex_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +9 -0
  61. data/lib/generators/lex_llm/install/templates/chat_model.rb.tt +3 -0
  62. data/lib/generators/lex_llm/install/templates/create_chats_migration.rb.tt +7 -0
  63. data/lib/generators/lex_llm/install/templates/create_messages_migration.rb.tt +19 -0
  64. data/lib/generators/lex_llm/install/templates/create_models_migration.rb.tt +39 -0
  65. data/lib/generators/lex_llm/install/templates/create_tool_calls_migration.rb.tt +21 -0
  66. data/lib/generators/lex_llm/install/templates/initializer.rb.tt +20 -0
  67. data/lib/generators/lex_llm/install/templates/message_model.rb.tt +4 -0
  68. data/lib/generators/lex_llm/install/templates/model_model.rb.tt +3 -0
  69. data/lib/generators/lex_llm/install/templates/tool_call_model.rb.tt +3 -0
  70. data/lib/generators/lex_llm/schema/schema_generator.rb +26 -0
  71. data/lib/generators/lex_llm/schema/templates/schema.rb.tt +2 -0
  72. data/lib/generators/lex_llm/tool/templates/tool.rb.tt +9 -0
  73. data/lib/generators/lex_llm/tool/templates/tool_call.html.erb.tt +13 -0
  74. data/lib/generators/lex_llm/tool/templates/tool_result.html.erb.tt +13 -0
  75. data/lib/generators/lex_llm/tool/tool_generator.rb +96 -0
  76. data/lib/generators/lex_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +19 -0
  77. data/lib/generators/lex_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +50 -0
  78. data/lib/generators/lex_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  79. data/lib/generators/lex_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  80. data/lib/generators/lex_llm/upgrade_to_v1_7/templates/migration.rb.tt +145 -0
  81. data/lib/generators/lex_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +122 -0
  82. data/lib/generators/lex_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +15 -0
  83. data/lib/generators/lex_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +49 -0
  84. data/lib/legion/extensions/llm/provider_settings.rb +49 -0
  85. data/lib/legion/extensions/llm/transport/fleet_lane.rb +70 -0
  86. data/lib/legion/extensions/llm.rb +50 -0
  87. data/lib/lex_llm/active_record/acts_as.rb +180 -0
  88. data/lib/lex_llm/active_record/acts_as_legacy.rb +503 -0
  89. data/lib/lex_llm/active_record/chat_methods.rb +468 -0
  90. data/lib/lex_llm/active_record/message_methods.rb +131 -0
  91. data/lib/lex_llm/active_record/model_methods.rb +76 -0
  92. data/lib/lex_llm/active_record/payload_helpers.rb +26 -0
  93. data/lib/lex_llm/active_record/tool_call_methods.rb +15 -0
  94. data/lib/lex_llm/agent.rb +365 -0
  95. data/lib/lex_llm/aliases.json +436 -0
  96. data/lib/lex_llm/aliases.rb +38 -0
  97. data/lib/lex_llm/attachment.rb +223 -0
  98. data/lib/lex_llm/chat.rb +351 -0
  99. data/lib/lex_llm/chunk.rb +6 -0
  100. data/lib/lex_llm/configuration.rb +81 -0
  101. data/lib/lex_llm/connection.rb +130 -0
  102. data/lib/lex_llm/content.rb +77 -0
  103. data/lib/lex_llm/context.rb +29 -0
  104. data/lib/lex_llm/embedding.rb +29 -0
  105. data/lib/lex_llm/error.rb +112 -0
  106. data/lib/lex_llm/image.rb +105 -0
  107. data/lib/lex_llm/message.rb +107 -0
  108. data/lib/lex_llm/mime_type.rb +71 -0
  109. data/lib/lex_llm/model/info.rb +113 -0
  110. data/lib/lex_llm/model/modalities.rb +22 -0
  111. data/lib/lex_llm/model/pricing.rb +48 -0
  112. data/lib/lex_llm/model/pricing_category.rb +46 -0
  113. data/lib/lex_llm/model/pricing_tier.rb +33 -0
  114. data/lib/lex_llm/model.rb +7 -0
  115. data/lib/lex_llm/models.json +57241 -0
  116. data/lib/lex_llm/models.rb +506 -0
  117. data/lib/lex_llm/models_schema.json +168 -0
  118. data/lib/lex_llm/moderation.rb +56 -0
  119. data/lib/lex_llm/provider.rb +278 -0
  120. data/lib/lex_llm/railtie.rb +35 -0
  121. data/lib/lex_llm/routing/lane_key.rb +51 -0
  122. data/lib/lex_llm/routing/model_offering.rb +169 -0
  123. data/lib/lex_llm/routing.rb +7 -0
  124. data/lib/lex_llm/stream_accumulator.rb +203 -0
  125. data/lib/lex_llm/streaming.rb +175 -0
  126. data/lib/lex_llm/thinking.rb +49 -0
  127. data/lib/lex_llm/tokens.rb +47 -0
  128. data/lib/lex_llm/tool.rb +254 -0
  129. data/lib/lex_llm/tool_call.rb +25 -0
  130. data/lib/lex_llm/transcription.rb +35 -0
  131. data/lib/lex_llm/utils.rb +91 -0
  132. data/lib/lex_llm/version.rb +5 -0
  133. data/lib/lex_llm.rb +95 -0
  134. data/lib/tasks/lex_llm.rake +23 -0
  135. metadata +349 -0
@@ -0,0 +1,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ # Represents a conversation with an AI model
5
+ class Chat
6
+ include Enumerable
7
+
8
+ attr_reader :model, :messages, :tools, :tool_prefs, :params, :headers, :schema
9
+
10
+ def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
11
+ if assume_model_exists && !provider
12
+ raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
13
+ end
14
+
15
+ @context = context
16
+ @config = context&.config || LexLLM.config
17
+ model_id = model || @config.default_model
18
+ with_model(model_id, provider: provider, assume_exists: assume_model_exists)
19
+ @temperature = nil
20
+ @messages = []
21
+ @tools = {}
22
+ @tool_prefs = { choice: nil, calls: nil }
23
+ @params = {}
24
+ @headers = {}
25
+ @schema = nil
26
+ @thinking = nil
27
+ @on = {
28
+ new_message: nil,
29
+ end_message: nil,
30
+ tool_call: nil,
31
+ tool_result: nil
32
+ }
33
+ end
34
+
35
+ def ask(message = nil, with: nil, &)
36
+ add_message role: :user, content: build_content(message, with)
37
+ complete(&)
38
+ end
39
+
40
+ alias say ask
41
+
42
+ def with_instructions(instructions, append: false, replace: nil)
43
+ append ||= (replace == false) unless replace.nil?
44
+
45
+ if append
46
+ append_system_instruction(instructions)
47
+ else
48
+ replace_system_instruction(instructions)
49
+ end
50
+
51
+ self
52
+ end
53
+
54
+ def with_tool(tool, choice: nil, calls: nil)
55
+ unless tool.nil?
56
+ tool_instance = tool.is_a?(Class) ? tool.new : tool
57
+ @tools[tool_instance.name.to_sym] = tool_instance
58
+ end
59
+ update_tool_options(choice:, calls:)
60
+ self
61
+ end
62
+
63
+ def with_tools(*tools, replace: false, choice: nil, calls: nil)
64
+ @tools.clear if replace
65
+ tools.compact.each { |tool| with_tool tool }
66
+ update_tool_options(choice:, calls:)
67
+ self
68
+ end
69
+
70
+ def with_model(model_id, provider: nil, assume_exists: false)
71
+ @model, @provider = Models.resolve(model_id, provider:, assume_exists:, config: @config)
72
+ @connection = @provider.connection
73
+ self
74
+ end
75
+
76
+ def with_temperature(temperature)
77
+ @temperature = temperature
78
+ self
79
+ end
80
+
81
+ def with_thinking(effort: nil, budget: nil)
82
+ raise ArgumentError, 'with_thinking requires :effort or :budget' if effort.nil? && budget.nil?
83
+
84
+ @thinking = Thinking::Config.new(effort: effort, budget: budget)
85
+ self
86
+ end
87
+
88
+ def with_context(context)
89
+ @context = context
90
+ @config = context.config
91
+ with_model(@model.id, provider: @provider.slug, assume_exists: true)
92
+ self
93
+ end
94
+
95
+ def with_params(**params)
96
+ @params = params
97
+ self
98
+ end
99
+
100
+ def with_headers(**headers)
101
+ @headers = headers
102
+ self
103
+ end
104
+
105
+ def with_schema(schema)
106
+ schema_instance = schema.is_a?(Class) ? schema.new : schema
107
+
108
+ @schema = normalize_schema_payload(
109
+ schema_instance.respond_to?(:to_json_schema) ? schema_instance.to_json_schema : schema_instance
110
+ )
111
+
112
+ self
113
+ end
114
+
115
+ def on_new_message(&block)
116
+ @on[:new_message] = block
117
+ self
118
+ end
119
+
120
+ def on_end_message(&block)
121
+ @on[:end_message] = block
122
+ self
123
+ end
124
+
125
+ def on_tool_call(&block)
126
+ @on[:tool_call] = block
127
+ self
128
+ end
129
+
130
+ def on_tool_result(&block)
131
+ @on[:tool_result] = block
132
+ self
133
+ end
134
+
135
+ def each(&)
136
+ messages.each(&)
137
+ end
138
+
139
+ def complete(&) # rubocop:disable Metrics/PerceivedComplexity
140
+ response = @provider.complete(
141
+ messages,
142
+ tools: @tools,
143
+ tool_prefs: @tool_prefs,
144
+ temperature: @temperature,
145
+ model: @model,
146
+ params: @params,
147
+ headers: @headers,
148
+ schema: @schema,
149
+ thinking: @thinking,
150
+ &wrap_streaming_block(&)
151
+ )
152
+
153
+ @on[:new_message]&.call unless block_given?
154
+
155
+ if @schema && response.content.is_a?(String) && !response.tool_call?
156
+ begin
157
+ response.content = Legion::JSON.parse(response.content, symbolize_names: false)
158
+ rescue Legion::JSON::ParseError
159
+ # If parsing fails, keep content as string
160
+ end
161
+ end
162
+
163
+ add_message response
164
+ @on[:end_message]&.call(response)
165
+
166
+ if response.tool_call?
167
+ handle_tool_calls(response, &)
168
+ else
169
+ response
170
+ end
171
+ end
172
+
173
+ def add_message(message_or_attributes)
174
+ message = message_or_attributes.is_a?(Message) ? message_or_attributes : Message.new(message_or_attributes)
175
+ messages << message
176
+ message
177
+ end
178
+
179
+ def reset_messages!
180
+ @messages.clear
181
+ end
182
+
183
+ def instance_variables
184
+ super - %i[@connection @config]
185
+ end
186
+
187
+ private
188
+
189
+ def normalize_schema_payload(raw_schema)
190
+ return nil if raw_schema.nil?
191
+ return raw_schema unless raw_schema.is_a?(Hash)
192
+
193
+ schema = LexLLM::Utils.deep_symbolize_keys(raw_schema)
194
+ schema_def = extract_schema_definition(schema)
195
+ strict = extract_schema_strict(schema, schema_def)
196
+ build_schema_payload(schema, schema_def, strict)
197
+ end
198
+
199
+ def extract_schema_definition(schema)
200
+ LexLLM::Utils.deep_dup(schema[:schema] || schema)
201
+ end
202
+
203
+ def extract_schema_strict(schema, schema_def)
204
+ return schema[:strict] if schema.key?(:strict)
205
+ return schema_def.delete(:strict) if schema_def.is_a?(Hash)
206
+
207
+ nil
208
+ end
209
+
210
+ def build_schema_payload(schema, schema_def, strict)
211
+ {
212
+ name: sanitize_schema_name(schema[:name] || 'response'),
213
+ schema: schema_def,
214
+ strict: strict.nil? || strict,
215
+ description: schema[:description]
216
+ }.compact
217
+ end
218
+
219
+ def sanitize_schema_name(name)
220
+ sanitized = name.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
221
+ sanitized.empty? ? 'response' : sanitized
222
+ end
223
+
224
+ def wrap_streaming_block(&block)
225
+ return nil unless block_given?
226
+
227
+ @on[:new_message]&.call
228
+
229
+ proc do |chunk|
230
+ block.call chunk
231
+ end
232
+ end
233
+
234
+ def handle_tool_calls(response, &) # rubocop:disable Metrics/PerceivedComplexity
235
+ halt_result = nil
236
+
237
+ response.tool_calls.each_value do |tool_call|
238
+ @on[:new_message]&.call
239
+ @on[:tool_call]&.call(tool_call)
240
+ result = execute_tool tool_call
241
+ @on[:tool_result]&.call(result)
242
+ tool_payload = result.is_a?(Tool::Halt) ? result.content : result
243
+ content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
244
+ message = add_message role: :tool, content:, tool_call_id: tool_call.id
245
+ @on[:end_message]&.call(message)
246
+
247
+ halt_result = result if result.is_a?(Tool::Halt)
248
+ end
249
+
250
+ reset_tool_choice if forced_tool_choice?
251
+ halt_result || complete(&)
252
+ end
253
+
254
+ def execute_tool(tool_call)
255
+ tool = tools[tool_call.name.to_sym]
256
+ if tool.nil?
257
+ return {
258
+ error: "Model tried to call unavailable tool `#{tool_call.name}`. " \
259
+ "Available tools: #{tools.keys.to_json}."
260
+ }
261
+ end
262
+
263
+ args = tool_call.arguments
264
+ tool.call(args)
265
+ end
266
+
267
+ def update_tool_options(choice:, calls:)
268
+ unless choice.nil?
269
+ normalized_choice = normalize_tool_choice(choice)
270
+ valid_tool_choices = %i[auto none required] + tools.keys
271
+ unless valid_tool_choices.include?(normalized_choice)
272
+ raise InvalidToolChoiceError,
273
+ "Invalid tool choice: #{choice}. Valid choices are: #{valid_tool_choices.join(', ')}"
274
+ end
275
+
276
+ @tool_prefs[:choice] = normalized_choice
277
+ end
278
+
279
+ @tool_prefs[:calls] = normalize_calls(calls) unless calls.nil?
280
+ end
281
+
282
+ def normalize_calls(calls)
283
+ case calls
284
+ when :many, 'many'
285
+ :many
286
+ when :one, 'one', 1
287
+ :one
288
+ else
289
+ raise ArgumentError, "Invalid calls value: #{calls.inspect}. Valid values are: :many, :one, or 1"
290
+ end
291
+ end
292
+
293
+ def normalize_tool_choice(choice)
294
+ return choice.to_sym if choice.is_a?(String) || choice.is_a?(Symbol)
295
+ return tool_name_for_choice_class(choice) if choice.is_a?(Class)
296
+
297
+ choice.respond_to?(:name) ? choice.name.to_sym : choice.to_sym
298
+ end
299
+
300
+ def tool_name_for_choice_class(tool_class)
301
+ matched_tool_name = tools.find { |_name, tool| tool.is_a?(tool_class) }&.first
302
+ return matched_tool_name if matched_tool_name
303
+
304
+ classify_tool_name(tool_class.name)
305
+ end
306
+
307
+ def classify_tool_name(class_name)
308
+ class_name.split('::').last
309
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
310
+ .downcase
311
+ .to_sym
312
+ end
313
+
314
+ def forced_tool_choice?
315
+ @tool_prefs[:choice] && !%i[auto none].include?(@tool_prefs[:choice])
316
+ end
317
+
318
+ def reset_tool_choice
319
+ @tool_prefs[:choice] = nil
320
+ end
321
+
322
+ def build_content(message, attachments)
323
+ return message if content_like?(message)
324
+
325
+ Content.new(message, attachments)
326
+ end
327
+
328
+ def content_like?(object)
329
+ object.is_a?(Content) || object.is_a?(Content::Raw)
330
+ end
331
+
332
+ def append_system_instruction(instructions)
333
+ system_messages, non_system_messages = @messages.partition { |msg| msg.role == :system }
334
+ system_messages << Message.new(role: :system, content: instructions)
335
+ @messages = system_messages + non_system_messages
336
+ end
337
+
338
+ def replace_system_instruction(instructions)
339
+ system_messages, non_system_messages = @messages.partition { |msg| msg.role == :system }
340
+
341
+ if system_messages.empty?
342
+ system_messages = [Message.new(role: :system, content: instructions)]
343
+ else
344
+ system_messages.first.content = instructions
345
+ system_messages = [system_messages.first]
346
+ end
347
+
348
+ @messages = system_messages + non_system_messages
349
+ end
350
+ end
351
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ class Chunk < Message
5
+ end
6
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ # Global configuration for LexLLM
5
+ class Configuration
6
+ class << self
7
+ # Declare a single configuration option.
8
+ def option(key, default = nil)
9
+ key = key.to_sym
10
+ return if options.include?(key)
11
+
12
+ send(:attr_accessor, key)
13
+ option_keys << key
14
+ defaults[key] = default
15
+ end
16
+
17
+ def register_provider_options(options)
18
+ Array(options).each { |key| option(key, nil) }
19
+ end
20
+
21
+ def options
22
+ option_keys.dup
23
+ end
24
+
25
+ private
26
+
27
+ def option_keys = @option_keys ||= []
28
+ def defaults = @defaults ||= {}
29
+ private :option
30
+ end
31
+
32
+ # System-level options are declared here.
33
+ # Provider-specific options are declared in each provider class via
34
+ # `self.configuration_options` and registered through Provider.register.
35
+ option :default_model, nil
36
+ option :default_embedding_model, nil
37
+ option :default_moderation_model, nil
38
+ option :default_image_model, nil
39
+ option :default_transcription_model, nil
40
+
41
+ option :model_registry_file, -> { File.expand_path('models.json', __dir__) }
42
+ option :model_registry_class, 'Model'
43
+
44
+ option :use_new_acts_as, false
45
+
46
+ option :request_timeout, 300
47
+ option :max_retries, 3
48
+ option :retry_interval, 0.1
49
+ option :retry_backoff_factor, 2
50
+ option :retry_interval_randomness, 0.5
51
+ option :http_proxy, nil
52
+
53
+ option :logger, nil
54
+ option :log_file, -> { $stdout }
55
+ option :log_level, -> { ENV['LEX_LLM_DEBUG'] ? Logger::DEBUG : Logger::INFO }
56
+ option :log_stream_debug, -> { ENV['LEX_LLM_STREAM_DEBUG'] == 'true' }
57
+ option :log_regexp_timeout, -> { Regexp.respond_to?(:timeout) ? (Regexp.timeout || 1.0) : nil }
58
+
59
+ def initialize
60
+ self.class.send(:defaults).each do |key, default|
61
+ value = default.respond_to?(:call) ? instance_exec(&default) : default
62
+ public_send("#{key}=", value)
63
+ end
64
+ end
65
+
66
+ def instance_variables
67
+ super.reject { |ivar| ivar.to_s.match?(/_id|_key|_secret|_token$/) }
68
+ end
69
+
70
+ def log_regexp_timeout=(value)
71
+ if value.nil?
72
+ @log_regexp_timeout = nil
73
+ elsif Regexp.respond_to?(:timeout)
74
+ @log_regexp_timeout = value
75
+ else
76
+ LexLLM.logger.warn("log_regexp_timeout is not supported on Ruby #{RUBY_VERSION}")
77
+ @log_regexp_timeout = value
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ # Connection class for managing API connections to various providers.
5
+ class Connection
6
+ attr_reader :provider, :connection, :config
7
+
8
+ def self.basic(&)
9
+ Faraday.new do |f|
10
+ f.response :logger,
11
+ LexLLM.logger,
12
+ bodies: false,
13
+ errors: true,
14
+ headers: false,
15
+ log_level: :debug
16
+ f.response :raise_error
17
+ yield f if block_given?
18
+ end
19
+ end
20
+
21
+ def initialize(provider, config)
22
+ @provider = provider
23
+ @config = config
24
+
25
+ ensure_configured!
26
+ @connection ||= Faraday.new(provider.api_base) do |faraday|
27
+ setup_timeout(faraday)
28
+ setup_logging(faraday)
29
+ setup_retry(faraday)
30
+ setup_middleware(faraday)
31
+ setup_http_proxy(faraday)
32
+ end
33
+ end
34
+
35
+ def post(url, payload, &)
36
+ @connection.post url, payload do |req|
37
+ req.headers.merge! @provider.headers if @provider.respond_to?(:headers)
38
+ yield req if block_given?
39
+ end
40
+ end
41
+
42
+ def get(url, &)
43
+ @connection.get url do |req|
44
+ req.headers.merge! @provider.headers if @provider.respond_to?(:headers)
45
+ yield req if block_given?
46
+ end
47
+ end
48
+
49
+ def instance_variables
50
+ super - %i[@config @connection]
51
+ end
52
+
53
+ private
54
+
55
+ def setup_timeout(faraday)
56
+ faraday.options.timeout = @config.request_timeout
57
+ end
58
+
59
+ def setup_logging(faraday)
60
+ faraday.response :logger,
61
+ LexLLM.logger,
62
+ bodies: LexLLM.logger.debug?,
63
+ errors: true,
64
+ headers: false,
65
+ log_level: :debug do |logger|
66
+ logger.filter(logging_regexp('[A-Za-z0-9+/=]{100,}'), '[BASE64 DATA]')
67
+ logger.filter(logging_regexp('[-\\d.e,\\s]{100,}'), '[EMBEDDINGS ARRAY]')
68
+ end
69
+ end
70
+
71
+ def logging_regexp(pattern)
72
+ return Regexp.new(pattern) if @config.log_regexp_timeout.nil? || !Regexp.respond_to?(:timeout)
73
+
74
+ Regexp.new(pattern, timeout: @config.log_regexp_timeout)
75
+ end
76
+
77
+ def setup_retry(faraday)
78
+ faraday.request :retry, {
79
+ max: @config.max_retries,
80
+ interval: @config.retry_interval,
81
+ interval_randomness: @config.retry_interval_randomness,
82
+ backoff_factor: @config.retry_backoff_factor,
83
+ methods: Faraday::Retry::Middleware::IDEMPOTENT_METHODS + [:post],
84
+ exceptions: retry_exceptions
85
+ }
86
+ end
87
+
88
+ def setup_middleware(faraday)
89
+ faraday.request :multipart
90
+ faraday.request :json
91
+ faraday.response :json
92
+ faraday.adapter :net_http
93
+ faraday.use :llm_errors, provider: @provider
94
+ end
95
+
96
+ def setup_http_proxy(faraday)
97
+ return unless @config.http_proxy
98
+
99
+ faraday.proxy = @config.http_proxy
100
+ end
101
+
102
+ def retry_exceptions
103
+ [
104
+ Errno::ETIMEDOUT,
105
+ Timeout::Error,
106
+ Faraday::TimeoutError,
107
+ Faraday::ConnectionFailed,
108
+ Faraday::RetriableResponse,
109
+ LexLLM::RateLimitError,
110
+ LexLLM::ServerError,
111
+ LexLLM::ServiceUnavailableError,
112
+ LexLLM::OverloadedError
113
+ ]
114
+ end
115
+
116
+ def ensure_configured!
117
+ return if @provider.configured?
118
+
119
+ missing = @provider.configuration_requirements.reject { |req| @config.send(req) }
120
+ config_block = <<~RUBY
121
+ LexLLM.configure do |config|
122
+ #{missing.map { |key| "config.#{key} = ENV['#{key.to_s.upcase}']" }.join("\n ")}
123
+ end
124
+ RUBY
125
+
126
+ raise ConfigurationError,
127
+ "#{@provider.name} provider is not configured. Add this to your initialization:\n\n#{config_block}"
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ # Represents the content sent to or received from an LLM.
5
+ class Content
6
+ attr_reader :text, :attachments
7
+
8
+ def initialize(text = nil, attachments = nil)
9
+ @text = text
10
+ @attachments = []
11
+
12
+ process_attachments(attachments)
13
+ raise ArgumentError, 'Text and attachments cannot be both nil' if @text.nil? && @attachments.empty?
14
+ end
15
+
16
+ def add_attachment(source, filename: nil)
17
+ @attachments << Attachment.new(source, filename:)
18
+ self
19
+ end
20
+
21
+ def format
22
+ if @text && @attachments.empty?
23
+ @text
24
+ else
25
+ self
26
+ end
27
+ end
28
+
29
+ # For Rails serialization
30
+ def to_h
31
+ { text: @text, attachments: @attachments.map(&:to_h) }
32
+ end
33
+
34
+ private
35
+
36
+ def process_attachments_array_or_string(attachments)
37
+ Utils.to_safe_array(attachments).each do |file|
38
+ next if blank_attachment_entry?(file)
39
+
40
+ add_attachment(file)
41
+ end
42
+ end
43
+
44
+ def blank_attachment_entry?(file)
45
+ file.nil? || (file.is_a?(String) && file.strip.empty?)
46
+ end
47
+
48
+ def process_attachments(attachments)
49
+ if attachments.is_a?(Hash)
50
+ attachments.each_value { |attachment| process_attachments_array_or_string(attachment) }
51
+ else
52
+ process_attachments_array_or_string attachments
53
+ end
54
+ end
55
+ end
56
+
57
+ class Content
58
+ # Represents provider-specific payloads that should bypass LexLLM formatting.
59
+ class Raw
60
+ attr_reader :value
61
+
62
+ def initialize(value)
63
+ raise ArgumentError, 'Raw content payload cannot be nil' if value.nil?
64
+
65
+ @value = value
66
+ end
67
+
68
+ def format
69
+ @value
70
+ end
71
+
72
+ def to_h
73
+ @value
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ # Holds per-call configs
5
+ class Context
6
+ attr_reader :config
7
+
8
+ def initialize(config)
9
+ @config = config
10
+ @connections = {}
11
+ end
12
+
13
+ def chat(*, **, &)
14
+ Chat.new(*, **, context: self, &)
15
+ end
16
+
17
+ def embed(*, **, &)
18
+ Embedding.embed(*, **, context: self, &)
19
+ end
20
+
21
+ def paint(*, **, &)
22
+ Image.paint(*, **, context: self, &)
23
+ end
24
+
25
+ def connection_for(provider_instance)
26
+ provider_instance.connection
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LexLLM
4
+ # Core embedding interface.
5
+ class Embedding
6
+ attr_reader :vectors, :model, :input_tokens
7
+
8
+ def initialize(vectors:, model:, input_tokens: 0)
9
+ @vectors = vectors
10
+ @model = model
11
+ @input_tokens = input_tokens
12
+ end
13
+
14
+ def self.embed(text, # rubocop:disable Metrics/ParameterLists
15
+ model: nil,
16
+ provider: nil,
17
+ assume_model_exists: false,
18
+ context: nil,
19
+ dimensions: nil)
20
+ config = context&.config || LexLLM.config
21
+ model ||= config.default_embedding_model
22
+ model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
23
+ config: config)
24
+ model_id = model.id
25
+
26
+ provider_instance.embed(text, model: model_id, dimensions:)
27
+ end
28
+ end
29
+ end