lex-llm 0.7.3 → 0.8.0
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/CHANGELOG.md +106 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
- data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
- data/lib/legion/extensions/llm/canonical/message.rb +52 -67
- data/lib/legion/extensions/llm/canonical/params.rb +59 -33
- data/lib/legion/extensions/llm/canonical/request.rb +67 -54
- data/lib/legion/extensions/llm/canonical/response.rb +61 -76
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
- data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
- data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
- data/lib/legion/extensions/llm/canonical.rb +6 -5
- data/lib/legion/extensions/llm/configuration.rb +40 -10
- data/lib/legion/extensions/llm/connection.rb +8 -30
- data/lib/legion/extensions/llm/credential_sources.rb +32 -49
- data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
- data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
- data/lib/legion/extensions/llm/error.rb +0 -14
- data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
- data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
- data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
- data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
- data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
- data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
- data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
- data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
- data/lib/legion/extensions/llm/inventory/records.rb +82 -99
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
- data/lib/legion/extensions/llm/provider.rb +192 -315
- data/lib/legion/extensions/llm/provider_contract.rb +25 -8
- data/lib/legion/extensions/llm/provider_settings.rb +5 -26
- data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
- data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
- data/lib/legion/extensions/llm/routing/records.rb +34 -17
- data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
- data/lib/legion/extensions/llm/streaming.rb +50 -35
- data/lib/legion/extensions/llm/taxonomies.rb +29 -17
- data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
- data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
- data/lib/legion/extensions/llm/utils.rb +23 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +10 -98
- data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
- data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
- data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
- data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
- data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
- data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
- data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
- data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
- data/spec/legion/extensions/llm/error_spec.rb +2 -12
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
- data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
- data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
- data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
- data/spec/legion/extensions/llm/provider_spec.rb +98 -78
- data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
- data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
- data/spec/legion/extensions/llm/utils_spec.rb +26 -7
- data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
- data/spec/legion/extensions/llm_extension_spec.rb +5 -5
- data/spec/support/fake_llm_provider.rb +45 -39
- data/spec/support/fake_ssot_harness.rb +7 -2
- data/spec/support/ssot_registry_helpers.rb +3 -2
- metadata +15 -54
- data/lib/legion/extensions/llm/agent.rb +0 -366
- data/lib/legion/extensions/llm/aliases.json +0 -436
- data/lib/legion/extensions/llm/aliases.rb +0 -67
- data/lib/legion/extensions/llm/attachment.rb +0 -229
- data/lib/legion/extensions/llm/chat.rb +0 -354
- data/lib/legion/extensions/llm/chunk.rb +0 -10
- data/lib/legion/extensions/llm/content.rb +0 -81
- data/lib/legion/extensions/llm/context.rb +0 -33
- data/lib/legion/extensions/llm/embedding.rb +0 -33
- data/lib/legion/extensions/llm/image.rb +0 -109
- data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
- data/lib/legion/extensions/llm/message.rb +0 -118
- data/lib/legion/extensions/llm/mime_type.rb +0 -75
- data/lib/legion/extensions/llm/model/info.rb +0 -286
- data/lib/legion/extensions/llm/model/modalities.rb +0 -26
- data/lib/legion/extensions/llm/model/pricing.rb +0 -52
- data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
- data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
- data/lib/legion/extensions/llm/model.rb +0 -11
- data/lib/legion/extensions/llm/models.json +0 -57313
- data/lib/legion/extensions/llm/models.rb +0 -530
- data/lib/legion/extensions/llm/models_schema.json +0 -168
- data/lib/legion/extensions/llm/moderation.rb +0 -60
- data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
- data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
- data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
- data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
- data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
- data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
- data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
- data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
- data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
- data/lib/legion/extensions/llm/thinking.rb +0 -53
- data/lib/legion/extensions/llm/tokens.rb +0 -51
- data/lib/legion/extensions/llm/tool_call.rb +0 -34
- data/lib/legion/extensions/llm/transcription.rb +0 -39
- data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
- data/spec/legion/extensions/llm/agent_spec.rb +0 -179
- data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
- data/spec/legion/extensions/llm/context_spec.rb +0 -127
- data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
- data/spec/legion/extensions/llm/message_spec.rb +0 -64
- data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
- data/spec/legion/extensions/llm/models_spec.rb +0 -104
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
|
@@ -1,229 +0,0 @@
|
|
|
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
|
-
include Legion::Logging::Helper
|
|
12
|
-
|
|
13
|
-
attr_reader :source, :filename, :mime_type
|
|
14
|
-
|
|
15
|
-
def initialize(source, filename: nil)
|
|
16
|
-
@source = source
|
|
17
|
-
@source = source_type_cast
|
|
18
|
-
@filename = filename || source_filename
|
|
19
|
-
|
|
20
|
-
determine_mime_type
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def url?
|
|
24
|
-
@source.is_a?(URI) || (@source.is_a?(String) && @source.match?(%r{^https?://}))
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def path?
|
|
28
|
-
@source.is_a?(Pathname) || (@source.is_a?(String) && !url?)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def io_like?
|
|
32
|
-
@source.respond_to?(:read) && !path? && !active_storage?
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def active_storage?
|
|
36
|
-
return false unless defined?(ActiveStorage)
|
|
37
|
-
|
|
38
|
-
@source.is_a?(ActiveStorage::Blob) ||
|
|
39
|
-
@source.is_a?(ActiveStorage::Attached::One) ||
|
|
40
|
-
@source.is_a?(ActiveStorage::Attached::Many)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def content
|
|
44
|
-
return @content if defined?(@content) && !@content.nil?
|
|
45
|
-
|
|
46
|
-
if url?
|
|
47
|
-
fetch_content
|
|
48
|
-
elsif path?
|
|
49
|
-
load_content_from_path
|
|
50
|
-
elsif active_storage?
|
|
51
|
-
load_content_from_active_storage
|
|
52
|
-
elsif io_like?
|
|
53
|
-
load_content_from_io
|
|
54
|
-
else
|
|
55
|
-
log.warn { "Source is neither a URL, path, ActiveStorage, nor IO-like: #{@source.class}" }
|
|
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
|
|
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
|
|
@@ -1,354 +0,0 @@
|
|
|
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
|
-
include Legion::Logging::Helper
|
|
10
|
-
|
|
11
|
-
attr_reader :model, :messages, :tools, :tool_prefs, :params, :headers, :schema
|
|
12
|
-
|
|
13
|
-
def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
|
|
14
|
-
raise ArgumentError, 'Provider must be specified if assume_model_exists is true' if assume_model_exists && !provider
|
|
15
|
-
|
|
16
|
-
@context = context
|
|
17
|
-
@config = context&.config || Legion::Extensions::Llm.config
|
|
18
|
-
model_id = model || @config.default_model
|
|
19
|
-
with_model(model_id, provider: provider, assume_exists: assume_model_exists)
|
|
20
|
-
@temperature = nil
|
|
21
|
-
@messages = []
|
|
22
|
-
@tools = {}
|
|
23
|
-
@tool_prefs = { choice: nil, calls: nil }
|
|
24
|
-
@params = {}
|
|
25
|
-
@headers = {}
|
|
26
|
-
@schema = nil
|
|
27
|
-
@thinking = nil
|
|
28
|
-
@on = {
|
|
29
|
-
new_message: nil,
|
|
30
|
-
end_message: nil,
|
|
31
|
-
tool_call: nil,
|
|
32
|
-
tool_result: nil
|
|
33
|
-
}
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def ask(message = nil, with: nil, &)
|
|
37
|
-
add_message role: :user, content: build_content(message, with)
|
|
38
|
-
complete(&)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
alias say ask
|
|
42
|
-
|
|
43
|
-
def with_instructions(instructions, append: false, replace: nil)
|
|
44
|
-
append ||= (replace == false) unless replace.nil?
|
|
45
|
-
|
|
46
|
-
if append
|
|
47
|
-
append_system_instruction(instructions)
|
|
48
|
-
else
|
|
49
|
-
replace_system_instruction(instructions)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
self
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def with_tool(tool, choice: nil, calls: nil)
|
|
56
|
-
unless tool.nil?
|
|
57
|
-
tool_instance = tool.is_a?(Class) ? tool.new : tool
|
|
58
|
-
@tools[tool_instance.name.to_sym] = tool_instance
|
|
59
|
-
end
|
|
60
|
-
update_tool_options(choice:, calls:)
|
|
61
|
-
self
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def with_tools(*tools, replace: false, choice: nil, calls: nil)
|
|
65
|
-
@tools.clear if replace
|
|
66
|
-
tools.compact.each { |tool| with_tool tool }
|
|
67
|
-
update_tool_options(choice:, calls:)
|
|
68
|
-
self
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def with_model(model_id, provider: nil, assume_exists: false)
|
|
72
|
-
@model, @provider = Models.resolve(model_id, provider:, assume_exists:, config: @config)
|
|
73
|
-
@connection = @provider.connection
|
|
74
|
-
self
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def with_temperature(temperature)
|
|
78
|
-
@temperature = temperature
|
|
79
|
-
self
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def with_thinking(effort: nil, budget: nil)
|
|
83
|
-
raise ArgumentError, 'with_thinking requires :effort or :budget' if effort.nil? && budget.nil?
|
|
84
|
-
|
|
85
|
-
@thinking = Thinking::Config.new(effort: effort, budget: budget)
|
|
86
|
-
self
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def with_context(context)
|
|
90
|
-
@context = context
|
|
91
|
-
@config = context.config
|
|
92
|
-
with_model(@model.id, provider: @provider.slug, assume_exists: true)
|
|
93
|
-
self
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def with_params(**params)
|
|
97
|
-
@params = params
|
|
98
|
-
self
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def with_headers(**headers)
|
|
102
|
-
@headers = headers
|
|
103
|
-
self
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def with_schema(schema)
|
|
107
|
-
schema_instance = schema.is_a?(Class) ? schema.new : schema
|
|
108
|
-
|
|
109
|
-
@schema = normalize_schema_payload(
|
|
110
|
-
schema_instance.respond_to?(:to_json_schema) ? schema_instance.to_json_schema : schema_instance
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
self
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def on_new_message(&block)
|
|
117
|
-
@on[:new_message] = block
|
|
118
|
-
self
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def on_end_message(&block)
|
|
122
|
-
@on[:end_message] = block
|
|
123
|
-
self
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def on_tool_call(&block)
|
|
127
|
-
@on[:tool_call] = block
|
|
128
|
-
self
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def on_tool_result(&block)
|
|
132
|
-
@on[:tool_result] = block
|
|
133
|
-
self
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def each(&)
|
|
137
|
-
messages.each(&)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def complete(&)
|
|
141
|
-
response = @provider.complete(
|
|
142
|
-
messages,
|
|
143
|
-
tools: @tools,
|
|
144
|
-
tool_prefs: @tool_prefs,
|
|
145
|
-
temperature: @temperature,
|
|
146
|
-
model: @model,
|
|
147
|
-
params: @params,
|
|
148
|
-
headers: @headers,
|
|
149
|
-
schema: @schema,
|
|
150
|
-
thinking: @thinking,
|
|
151
|
-
&wrap_streaming_block(&)
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
@on[:new_message]&.call unless block_given?
|
|
155
|
-
|
|
156
|
-
if @schema && response.content.is_a?(String) && !response.tool_call?
|
|
157
|
-
begin
|
|
158
|
-
response.content = Legion::JSON.parse(response.content, symbolize_names: false)
|
|
159
|
-
rescue Legion::JSON::ParseError => e
|
|
160
|
-
handle_exception(e, level: :warn, handled: true, operation: 'llm.chat.complete')
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
add_message response
|
|
165
|
-
@on[:end_message]&.call(response)
|
|
166
|
-
|
|
167
|
-
if response.tool_call?
|
|
168
|
-
handle_tool_calls(response, &)
|
|
169
|
-
else
|
|
170
|
-
response
|
|
171
|
-
end
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
def add_message(message_or_attributes)
|
|
175
|
-
message = message_or_attributes.is_a?(Message) ? message_or_attributes : Message.new(message_or_attributes)
|
|
176
|
-
messages << message
|
|
177
|
-
message
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def reset_messages!
|
|
181
|
-
@messages.clear
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def instance_variables
|
|
185
|
-
super - %i[@connection @config]
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
private
|
|
189
|
-
|
|
190
|
-
def normalize_schema_payload(raw_schema)
|
|
191
|
-
return nil if raw_schema.nil?
|
|
192
|
-
return raw_schema unless raw_schema.is_a?(Hash)
|
|
193
|
-
|
|
194
|
-
schema = Legion::Extensions::Llm::Utils.deep_symbolize_keys(raw_schema)
|
|
195
|
-
schema_def = extract_schema_definition(schema)
|
|
196
|
-
strict = extract_schema_strict(schema, schema_def)
|
|
197
|
-
build_schema_payload(schema, schema_def, strict)
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def extract_schema_definition(schema)
|
|
201
|
-
Legion::Extensions::Llm::Utils.deep_dup(schema[:schema] || schema)
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def extract_schema_strict(schema, schema_def)
|
|
205
|
-
return schema[:strict] if schema.key?(:strict)
|
|
206
|
-
return schema_def.delete(:strict) if schema_def.is_a?(Hash)
|
|
207
|
-
|
|
208
|
-
nil
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def build_schema_payload(schema, schema_def, strict)
|
|
212
|
-
{
|
|
213
|
-
name: sanitize_schema_name(schema[:name] || 'response'),
|
|
214
|
-
schema: schema_def,
|
|
215
|
-
strict: strict.nil? || strict,
|
|
216
|
-
description: schema[:description]
|
|
217
|
-
}.compact
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def sanitize_schema_name(name)
|
|
221
|
-
sanitized = name.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
|
|
222
|
-
sanitized.empty? ? 'response' : sanitized
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
def wrap_streaming_block(&block)
|
|
226
|
-
return nil unless block_given?
|
|
227
|
-
|
|
228
|
-
@on[:new_message]&.call
|
|
229
|
-
|
|
230
|
-
proc do |chunk|
|
|
231
|
-
block.call chunk
|
|
232
|
-
end
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
def handle_tool_calls(response, &)
|
|
236
|
-
halt_result = nil
|
|
237
|
-
|
|
238
|
-
response.tool_calls.each_value do |tool_call|
|
|
239
|
-
@on[:new_message]&.call
|
|
240
|
-
@on[:tool_call]&.call(tool_call)
|
|
241
|
-
result = execute_tool tool_call
|
|
242
|
-
@on[:tool_result]&.call(result)
|
|
243
|
-
tool_payload = result.is_a?(Tool::Halt) ? result.content : result
|
|
244
|
-
content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
|
|
245
|
-
message = add_message role: :tool, content:, tool_call_id: tool_call.id
|
|
246
|
-
@on[:end_message]&.call(message)
|
|
247
|
-
|
|
248
|
-
halt_result = result if result.is_a?(Tool::Halt)
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
reset_tool_choice if forced_tool_choice?
|
|
252
|
-
halt_result || complete(&)
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
def execute_tool(tool_call)
|
|
256
|
-
tool = tools[tool_call.name.to_sym]
|
|
257
|
-
if tool.nil?
|
|
258
|
-
return {
|
|
259
|
-
error: "Model tried to call unavailable tool `#{tool_call.name}`. " \
|
|
260
|
-
"Available tools: #{tools.keys.to_json}."
|
|
261
|
-
}
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
args = tool_call.arguments
|
|
265
|
-
tool.call(args)
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
def update_tool_options(choice:, calls:)
|
|
269
|
-
unless choice.nil?
|
|
270
|
-
normalized_choice = normalize_tool_choice(choice)
|
|
271
|
-
valid_tool_choices = %i[auto none required] + tools.keys
|
|
272
|
-
unless valid_tool_choices.include?(normalized_choice)
|
|
273
|
-
raise InvalidToolChoiceError,
|
|
274
|
-
"Invalid tool choice: #{choice}. Valid choices are: #{valid_tool_choices.join(', ')}"
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
@tool_prefs[:choice] = normalized_choice
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
@tool_prefs[:calls] = normalize_calls(calls) unless calls.nil?
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
def normalize_calls(calls)
|
|
284
|
-
case calls
|
|
285
|
-
when :many, 'many'
|
|
286
|
-
:many
|
|
287
|
-
when :one, 'one', 1
|
|
288
|
-
:one
|
|
289
|
-
else
|
|
290
|
-
raise ArgumentError, "Invalid calls value: #{calls.inspect}. Valid values are: :many, :one, or 1"
|
|
291
|
-
end
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
def normalize_tool_choice(choice)
|
|
295
|
-
return choice.to_sym if choice.is_a?(String) || choice.is_a?(Symbol)
|
|
296
|
-
return tool_name_for_choice_class(choice) if choice.is_a?(Class)
|
|
297
|
-
|
|
298
|
-
choice.respond_to?(:name) ? choice.name.to_sym : choice.to_sym
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
def tool_name_for_choice_class(tool_class)
|
|
302
|
-
matched_tool_name = tools.find { |_name, tool| tool.is_a?(tool_class) }&.first
|
|
303
|
-
return matched_tool_name if matched_tool_name
|
|
304
|
-
|
|
305
|
-
classify_tool_name(tool_class.name)
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
def classify_tool_name(class_name)
|
|
309
|
-
class_name.split('::').last
|
|
310
|
-
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
311
|
-
.downcase
|
|
312
|
-
.to_sym
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
def forced_tool_choice?
|
|
316
|
-
@tool_prefs[:choice] && !%i[auto none].include?(@tool_prefs[:choice])
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
def reset_tool_choice
|
|
320
|
-
@tool_prefs[:choice] = nil
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
def build_content(message, attachments)
|
|
324
|
-
return message if content_like?(message)
|
|
325
|
-
|
|
326
|
-
Content.new(message, attachments)
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
def content_like?(object)
|
|
330
|
-
object.is_a?(Content) || object.is_a?(Content::Raw)
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
def append_system_instruction(instructions)
|
|
334
|
-
system_messages, non_system_messages = @messages.partition { |msg| msg.role == :system }
|
|
335
|
-
system_messages << Message.new(role: :system, content: instructions)
|
|
336
|
-
@messages = system_messages + non_system_messages
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
def replace_system_instruction(instructions)
|
|
340
|
-
system_messages, non_system_messages = @messages.partition { |msg| msg.role == :system }
|
|
341
|
-
|
|
342
|
-
if system_messages.empty?
|
|
343
|
-
system_messages = [Message.new(role: :system, content: instructions)]
|
|
344
|
-
else
|
|
345
|
-
system_messages.first.content = instructions
|
|
346
|
-
system_messages = [system_messages.first]
|
|
347
|
-
end
|
|
348
|
-
|
|
349
|
-
@messages = system_messages + non_system_messages
|
|
350
|
-
end
|
|
351
|
-
end
|
|
352
|
-
end
|
|
353
|
-
end
|
|
354
|
-
end
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Legion
|
|
4
|
-
module Extensions
|
|
5
|
-
module Llm
|
|
6
|
-
# Represents the content sent to or received from an LLM.
|
|
7
|
-
class Content
|
|
8
|
-
attr_reader :text, :attachments
|
|
9
|
-
|
|
10
|
-
def initialize(text = nil, attachments = nil)
|
|
11
|
-
@text = text
|
|
12
|
-
@attachments = []
|
|
13
|
-
|
|
14
|
-
process_attachments(attachments)
|
|
15
|
-
raise ArgumentError, 'Text and attachments cannot be both nil' if @text.nil? && @attachments.empty?
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def add_attachment(source, filename: nil)
|
|
19
|
-
@attachments << Attachment.new(source, filename:)
|
|
20
|
-
self
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def format
|
|
24
|
-
if @text && @attachments.empty?
|
|
25
|
-
@text
|
|
26
|
-
else
|
|
27
|
-
self
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Allows serializers to store the text payload without custom adapters.
|
|
32
|
-
def to_h
|
|
33
|
-
{ text: @text, attachments: @attachments.map(&:to_h) }
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
private
|
|
37
|
-
|
|
38
|
-
def process_attachments_array_or_string(attachments)
|
|
39
|
-
Utils.to_safe_array(attachments).each do |file|
|
|
40
|
-
next if blank_attachment_entry?(file)
|
|
41
|
-
|
|
42
|
-
add_attachment(file)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def blank_attachment_entry?(file)
|
|
47
|
-
file.nil? || (file.is_a?(String) && file.strip.empty?)
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def process_attachments(attachments)
|
|
51
|
-
if attachments.is_a?(Hash)
|
|
52
|
-
attachments.each_value { |attachment| process_attachments_array_or_string(attachment) }
|
|
53
|
-
else
|
|
54
|
-
process_attachments_array_or_string attachments
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
class Content
|
|
60
|
-
# Represents provider-specific payloads that should bypass Legion::Extensions::Llm formatting.
|
|
61
|
-
class Raw
|
|
62
|
-
attr_reader :value
|
|
63
|
-
|
|
64
|
-
def initialize(value)
|
|
65
|
-
raise ArgumentError, 'Raw content payload cannot be nil' if value.nil?
|
|
66
|
-
|
|
67
|
-
@value = value
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def format
|
|
71
|
-
@value
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def to_h
|
|
75
|
-
@value
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
end
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Legion
|
|
4
|
-
module Extensions
|
|
5
|
-
module Llm
|
|
6
|
-
# Holds per-call configs
|
|
7
|
-
class Context
|
|
8
|
-
attr_reader :config
|
|
9
|
-
|
|
10
|
-
def initialize(config)
|
|
11
|
-
@config = config
|
|
12
|
-
@connections = {}
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def chat(*, **, &)
|
|
16
|
-
Chat.new(*, **, context: self, &)
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def embed(*, **, &)
|
|
20
|
-
Embedding.embed(*, **, context: self, &)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def paint(*, **, &)
|
|
24
|
-
Image.paint(*, **, context: self, &)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def connection_for(provider_instance)
|
|
28
|
-
provider_instance.connection
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|