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.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +52 -67
  8. data/lib/legion/extensions/llm/canonical/params.rb +59 -33
  9. data/lib/legion/extensions/llm/canonical/request.rb +67 -54
  10. data/lib/legion/extensions/llm/canonical/response.rb +61 -76
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
  13. data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
  14. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  15. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  16. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  17. data/lib/legion/extensions/llm/canonical.rb +6 -5
  18. data/lib/legion/extensions/llm/configuration.rb +40 -10
  19. data/lib/legion/extensions/llm/connection.rb +8 -30
  20. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  21. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  22. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  23. data/lib/legion/extensions/llm/error.rb +0 -14
  24. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  25. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  26. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  27. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  28. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
  29. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  30. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
  31. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  32. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  33. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  34. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  35. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  36. data/lib/legion/extensions/llm/inventory/records.rb +82 -99
  37. data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
  38. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  39. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
  40. data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
  41. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
  42. data/lib/legion/extensions/llm/provider.rb +192 -315
  43. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  44. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  45. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  46. data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
  47. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  48. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  49. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  50. data/lib/legion/extensions/llm/streaming.rb +50 -35
  51. data/lib/legion/extensions/llm/taxonomies.rb +29 -17
  52. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  53. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  54. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  55. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  56. data/lib/legion/extensions/llm/utils.rb +23 -5
  57. data/lib/legion/extensions/llm/version.rb +1 -1
  58. data/lib/legion/extensions/llm.rb +10 -98
  59. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  60. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  61. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  62. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  63. data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
  64. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  65. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  66. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
  67. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  68. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  69. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  70. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  71. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  72. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  73. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  74. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  75. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
  76. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
  77. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  78. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  79. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  80. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  81. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
  82. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  83. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
  84. data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
  85. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  86. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  87. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  88. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  89. data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
  90. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
  91. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  92. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  93. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
  94. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
  95. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
  96. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  97. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  98. data/spec/legion/extensions/llm/provider_spec.rb +98 -78
  99. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  100. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  101. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  102. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
  103. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  104. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  105. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  106. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  107. data/spec/support/fake_llm_provider.rb +45 -39
  108. data/spec/support/fake_ssot_harness.rb +7 -2
  109. data/spec/support/ssot_registry_helpers.rb +3 -2
  110. metadata +15 -54
  111. data/lib/legion/extensions/llm/agent.rb +0 -366
  112. data/lib/legion/extensions/llm/aliases.json +0 -436
  113. data/lib/legion/extensions/llm/aliases.rb +0 -67
  114. data/lib/legion/extensions/llm/attachment.rb +0 -229
  115. data/lib/legion/extensions/llm/chat.rb +0 -354
  116. data/lib/legion/extensions/llm/chunk.rb +0 -10
  117. data/lib/legion/extensions/llm/content.rb +0 -81
  118. data/lib/legion/extensions/llm/context.rb +0 -33
  119. data/lib/legion/extensions/llm/embedding.rb +0 -33
  120. data/lib/legion/extensions/llm/image.rb +0 -109
  121. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  122. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
  123. data/lib/legion/extensions/llm/message.rb +0 -118
  124. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  125. data/lib/legion/extensions/llm/model/info.rb +0 -286
  126. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  127. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  128. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  129. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  130. data/lib/legion/extensions/llm/model.rb +0 -11
  131. data/lib/legion/extensions/llm/models.json +0 -57313
  132. data/lib/legion/extensions/llm/models.rb +0 -530
  133. data/lib/legion/extensions/llm/models_schema.json +0 -168
  134. data/lib/legion/extensions/llm/moderation.rb +0 -60
  135. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  136. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  137. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  138. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  139. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  140. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  141. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  142. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  143. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  144. data/lib/legion/extensions/llm/thinking.rb +0 -53
  145. data/lib/legion/extensions/llm/tokens.rb +0 -51
  146. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  147. data/lib/legion/extensions/llm/transcription.rb +0 -39
  148. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  149. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  150. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  151. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  152. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  153. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  154. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
  155. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  156. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  157. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  158. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  159. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  160. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  161. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  162. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  163. 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,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module Extensions
5
- module Llm
6
- class Chunk < Message
7
- end
8
- end
9
- end
10
- end
@@ -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