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
data/lib/lex_llm/chat.rb
ADDED
|
@@ -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,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
|