lex-llm 0.1.7 → 0.1.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5b58678c0d7021662b2ef38d80932bd373e3c462b9d05e1004dfc880b1e6d6f
4
- data.tar.gz: 49a88cc742e128df1bd93882585df89e595c9761194da354af6be93bd4bd4c2e
3
+ metadata.gz: 0ca7422981b9d63c85ebe052990cd12af77c9e625ce7597d67e8161418a94ea3
4
+ data.tar.gz: 1cd31a58aa6bc9f35a9e8d45c1bed00c05b7dbdcfaaddb81bb29f644787a7703
5
5
  SHA512:
6
- metadata.gz: 273c724d3b7b2945dea092184c8df80952d6ec0c8c38aefbba966a28de91c43c2862be91ac9e918943a7e2e42b5dde4c7b98826852598c7e82c4dd10bbca26e8
7
- data.tar.gz: 68b38d28e88ad07c333ca0f7e94885a3006b1f3fc727f3c2b7206cba5497b42f848927b2d555db5382abac05123b76074572c7ca6834da0de312b2f30fdd3a03
6
+ metadata.gz: d0de80f09c6820c95b51297bc3a6b0bdebfdaaedb007668c635b670c45668e80c1ed4a9eb0c2cff5db48aef602c1aa4e9f325b734ed63f661fbec4248589aa26
7
+ data.tar.gz: 8bfed0a1cec4ec06062c6797afed865f7fec9757b7906022dfd04bd9650b2b3bd5bc67c6cda65f3464bc3f8da1c0a59543d573ae30231bcc2874b307a0fb6e85
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.9 - 2026-04-30
4
+
5
+ - Replace Model::Info class with immutable Data.define value object supporting new fields: instance, parameter_count, parameter_size, quantization, size_bytes, modalities_input, modalities_output
6
+ - Add Model::Info.from_hash factory for backward-compatible construction from legacy hash format
7
+ - Add backward-compatible accessors on Model::Info for context_window, max_output_tokens, created_at, knowledge_cutoff, modalities, pricing, type, and legacy capability predicates
8
+ - Add model_allowed? to base Provider with whitelist/blacklist filtering from settings
9
+ - Add multi-host base_url resolution with TLS awareness and reachability probing
10
+ - Add cache tier selection helpers: cache_local_instance?, model_cache_get/set/fetch, cache_instance_key for local vs shared cache routing
11
+ - Add shared transport classes for llm.registry exchange and registry event messages (guarded by defined? for optional legion-transport)
12
+ - Add shared RegistryPublisher parameterized by provider_family for all lex-llm-* gems
13
+ - Add shared RegistryEventBuilder parameterized by provider_family for all lex-llm-* gems
14
+ - Mark Provider.register, .resolve, .for, .providers with @deprecated annotations for future removal in favor of the extension registry
15
+
16
+ ## 0.1.8 - 2026-04-30
17
+
18
+ - Audit all rescue blocks for handle_exception compliance
19
+ - Add Legion::Logging::Helper to Provider, Chat, and Models for structured exception reporting
20
+ - Replace ad-hoc logger.debug/warn calls in rescue blocks with handle_exception across streaming, chat, models, and provider modules
21
+ - Add require for legion/logging in the main entrypoint
22
+
3
23
  ## 0.1.7 - 2026-04-30
4
24
 
5
25
  - Add thinking extraction from OpenAI-compatible streaming chunks (reasoning_content, reasoning, think tags)
@@ -6,6 +6,7 @@ module Legion
6
6
  # Represents a conversation with an AI model
7
7
  class Chat
8
8
  include Enumerable
9
+ include Legion::Logging::Helper
9
10
 
10
11
  attr_reader :model, :messages, :tools, :tool_prefs, :params, :headers, :schema
11
12
 
@@ -157,8 +158,8 @@ module Legion
157
158
  if @schema && response.content.is_a?(String) && !response.tool_call?
158
159
  begin
159
160
  response.content = Legion::JSON.parse(response.content, symbolize_names: false)
160
- rescue Legion::JSON::ParseError
161
- # If parsing fails, keep content as string
161
+ rescue Legion::JSON::ParseError => e
162
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.chat.complete')
162
163
  end
163
164
  end
164
165
 
@@ -4,71 +4,92 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Model
7
- # Information about an AI model's capabilities, pricing, and metadata.
8
- class Info
9
- attr_reader :id, :name, :provider, :family, :created_at, :context_window, :max_output_tokens,
10
- :knowledge_cutoff, :modalities, :capabilities, :pricing, :metadata
11
-
12
- # Create a default model with assumed capabilities
13
- def self.default(model_id, provider)
14
- new(
15
- id: model_id,
16
- name: model_id.tr('-', ' ').capitalize,
17
- provider: provider,
18
- capabilities: %w[function_calling streaming vision structured_output],
19
- modalities: { input: %w[text image], output: %w[text] },
20
- metadata: { warning: 'Assuming model exists, capabilities may not be accurate' }
7
+ Info = Data.define(
8
+ :id, :name, :provider, :instance, :family,
9
+ :capabilities, :context_length, :parameter_count,
10
+ :parameter_size, :quantization, :size_bytes,
11
+ :modalities_input, :modalities_output, :metadata
12
+ ) do
13
+ # rubocop:disable Metrics/ParameterLists, Metrics/PerceivedComplexity
14
+ def initialize(
15
+ id:, name: nil, provider: nil, instance: :default,
16
+ family: nil, capabilities: [], context_length: nil,
17
+ parameter_count: nil, parameter_size: nil, quantization: nil,
18
+ size_bytes: nil, modalities_input: [], modalities_output: [],
19
+ metadata: {}
20
+ )
21
+ normalized_family = family.nil? ? nil : family.to_s.downcase.strip
22
+
23
+ super(
24
+ id: id.to_s.strip,
25
+ name: (name || id).to_s.strip,
26
+ provider: provider.to_s.downcase.to_sym,
27
+ instance: (instance || :default).to_s.downcase.to_sym,
28
+ family: normalized_family,
29
+ capabilities: normalize_symbols(capabilities),
30
+ context_length: to_int(context_length),
31
+ parameter_count: to_int(parameter_count),
32
+ parameter_size: parameter_size&.to_s&.strip,
33
+ quantization: quantization&.to_s&.strip,
34
+ size_bytes: to_int(size_bytes),
35
+ modalities_input: normalize_symbols(modalities_input),
36
+ modalities_output: normalize_symbols(modalities_output),
37
+ metadata: metadata.is_a?(Hash) ? metadata : {}
21
38
  )
22
39
  end
40
+ # rubocop:enable Metrics/ParameterLists, Metrics/PerceivedComplexity
23
41
 
24
- def initialize(data)
25
- @id = data[:id]
26
- @name = data[:name]
27
- @provider = data[:provider]
28
- @family = data[:family]
29
- @created_at = Utils.to_time(data[:created_at])&.utc
30
- @context_window = data[:context_window]
31
- @max_output_tokens = data[:max_output_tokens]
32
- @knowledge_cutoff = Utils.to_date(data[:knowledge_cutoff])
33
- @modalities = Modalities.new(data[:modalities] || {})
34
- @capabilities = data[:capabilities] || []
35
- @pricing = Pricing.new(data[:pricing] || {})
36
- @metadata = data[:metadata] || {}
37
- end
42
+ # ── Capability predicates ─────────────────────────────────────
43
+
44
+ def completion? = capabilities.include?(:completion)
45
+ def embedding? = capabilities.include?(:embedding)
46
+ def vision? = capabilities.include?(:vision)
47
+ def tools? = capabilities.include?(:tools)
48
+ def thinking? = capabilities.include?(:thinking)
38
49
 
39
50
  def supports?(capability)
40
- capabilities.include?(capability.to_s)
51
+ capabilities.include?(capability.to_s.downcase.to_sym)
41
52
  end
42
53
 
43
- %w[function_calling structured_output batch reasoning citations streaming].each do |cap|
44
- define_method "#{cap}?" do
45
- supports?(cap)
46
- end
47
- end
54
+ # ── Backward-compatible accessors ─────────────────────────────
55
+ # These bridge the legacy Model::Info class API used by Models,
56
+ # OpenAICompatible, and provider gems. They read from metadata
57
+ # where the old fields were stored.
48
58
 
49
- def display_name
50
- name
59
+ def context_window
60
+ context_length || metadata[:context_window]
51
61
  end
52
62
 
53
- def label
54
- provider_name = provider_class&.name || provider
55
- "#{provider_name} - #{display_name}"
63
+ def max_output_tokens
64
+ metadata[:max_output_tokens]
56
65
  end
57
66
 
58
67
  def max_tokens
59
68
  max_output_tokens
60
69
  end
61
70
 
62
- def supports_vision?
63
- modalities.input.include?('image')
71
+ def created_at
72
+ metadata[:created_at]
64
73
  end
65
74
 
66
- def supports_video?
67
- modalities.input.include?('video')
75
+ def knowledge_cutoff
76
+ metadata[:knowledge_cutoff]
68
77
  end
69
78
 
70
- def supports_functions?
71
- function_calling?
79
+ def modalities
80
+ Modalities.new(input: modalities_input.map(&:to_s), output: modalities_output.map(&:to_s))
81
+ end
82
+
83
+ def pricing
84
+ Pricing.new(metadata[:pricing] || {})
85
+ end
86
+
87
+ def display_name
88
+ name
89
+ end
90
+
91
+ def label
92
+ "#{provider} - #{display_name}"
72
93
  end
73
94
 
74
95
  def input_price_per_million
@@ -79,13 +100,28 @@ module Legion
79
100
  pricing.text_tokens.output
80
101
  end
81
102
 
82
- def provider_class
83
- Legion::Extensions::Llm::Provider.resolve provider
103
+ def supports_vision?
104
+ vision? || modalities_input.include?(:image)
105
+ end
106
+
107
+ def supports_video?
108
+ modalities_input.include?(:video)
109
+ end
110
+
111
+ def supports_functions?
112
+ tools? || capabilities.include?(:function_calling)
113
+ end
114
+
115
+ # Legacy capability predicates (string-based)
116
+ %w[function_calling structured_output batch reasoning citations streaming].each do |cap|
117
+ define_method "#{cap}?" do
118
+ supports?(cap)
119
+ end
84
120
  end
85
121
 
86
122
  def type
87
- output = modalities.output
88
- return 'embedding' if output.include?('embeddings')
123
+ output = modalities_output.map(&:to_s)
124
+ return 'embedding' if output.include?('embeddings') || embedding?
89
125
  return 'moderation' if output.include?('moderation')
90
126
  return 'image' if output.include?('image')
91
127
  return 'audio' if output.include?('audio')
@@ -94,21 +130,123 @@ module Legion
94
130
  'chat'
95
131
  end
96
132
 
97
- def to_h
98
- {
99
- id: id,
100
- name: name,
133
+ # Factory for assumed-to-exist models without full metadata.
134
+ def self.default(model_id, provider)
135
+ new(
136
+ id: model_id,
137
+ name: model_id.tr('-', ' ').capitalize,
101
138
  provider: provider,
102
- family: family,
103
- created_at: created_at,
104
- context_window: context_window,
105
- max_output_tokens: max_output_tokens,
106
- knowledge_cutoff: knowledge_cutoff,
107
- modalities: modalities.to_h,
108
- capabilities: capabilities,
109
- pricing: pricing.to_h,
110
- metadata: metadata
111
- }
139
+ capabilities: %w[function_calling streaming vision structured_output],
140
+ modalities_input: %w[text image],
141
+ modalities_output: %w[text],
142
+ metadata: { warning: 'Assuming model exists, capabilities may not be accurate' }
143
+ )
144
+ end
145
+
146
+ # Factory that accepts both legacy and new-style hashes and maps
147
+ # them to the new struct fields. Handles round-tripping through to_h.
148
+ def self.from_hash(data)
149
+ data = data.transform_keys(&:to_sym) if data.is_a?(Hash)
150
+
151
+ input_mods, output_mods = extract_modalities(data)
152
+
153
+ new(
154
+ id: data[:id],
155
+ name: data[:name],
156
+ provider: data[:provider],
157
+ instance: data[:instance],
158
+ family: data[:family],
159
+ capabilities: data[:capabilities] || [],
160
+ context_length: data[:context_length] || data[:context_window],
161
+ parameter_count: data[:parameter_count],
162
+ parameter_size: data[:parameter_size],
163
+ quantization: data[:quantization],
164
+ size_bytes: data[:size_bytes],
165
+ modalities_input: input_mods,
166
+ modalities_output: output_mods,
167
+ metadata: build_metadata(data)
168
+ )
169
+ end
170
+
171
+ private
172
+
173
+ def normalize_symbols(value)
174
+ Array(value).map { |v| v.to_s.downcase.strip.to_sym }.uniq
175
+ end
176
+
177
+ def to_int(value)
178
+ return nil if value.nil?
179
+
180
+ value.to_i
181
+ end
182
+
183
+ # Class-level helpers for from_hash normalization
184
+ class << self
185
+ private
186
+
187
+ def extract_modalities(data) # rubocop:disable Metrics/PerceivedComplexity
188
+ # New-style keys take priority (round-trip from to_h)
189
+ if data.key?(:modalities_input) || data.key?(:modalities_output)
190
+ return [Array(data[:modalities_input]), Array(data[:modalities_output])]
191
+ end
192
+
193
+ # Legacy: modalities is a hash or Modalities object
194
+ modalities_data = data[:modalities]
195
+ input_mods = if modalities_data.respond_to?(:input)
196
+ modalities_data.input
197
+ elsif modalities_data.is_a?(Hash)
198
+ Array(modalities_data[:input])
199
+ else
200
+ []
201
+ end
202
+ output_mods = if modalities_data.respond_to?(:output)
203
+ modalities_data.output
204
+ elsif modalities_data.is_a?(Hash)
205
+ Array(modalities_data[:output])
206
+ else
207
+ []
208
+ end
209
+ [input_mods, output_mods]
210
+ end
211
+
212
+ def build_metadata(data)
213
+ extra = {}
214
+ extra[:created_at] = normalize_created_at(data[:created_at]) if data.key?(:created_at)
215
+ if data.key?(:knowledge_cutoff)
216
+ extra[:knowledge_cutoff] =
217
+ normalize_knowledge_cutoff(data[:knowledge_cutoff])
218
+ end
219
+ extra[:max_output_tokens] = data[:max_output_tokens] if data.key?(:max_output_tokens)
220
+ extra[:pricing] = normalize_pricing(data[:pricing]) if data.key?(:pricing)
221
+
222
+ base = data[:metadata] || {}
223
+ base.merge(extra).compact
224
+ end
225
+
226
+ def normalize_created_at(value)
227
+ return nil if value.nil?
228
+ return value if value.is_a?(Time)
229
+
230
+ Utils.to_time(value)&.utc
231
+ rescue StandardError
232
+ nil
233
+ end
234
+
235
+ def normalize_knowledge_cutoff(value)
236
+ return nil if value.nil?
237
+ return value if value.is_a?(Date)
238
+
239
+ Utils.to_date(value)
240
+ rescue StandardError
241
+ nil
242
+ end
243
+
244
+ def normalize_pricing(value)
245
+ return nil if value.nil?
246
+ return value.to_h if value.respond_to?(:to_h)
247
+
248
+ value
249
+ end
112
250
  end
113
251
  end
114
252
  end
@@ -35,6 +35,8 @@ module Legion
35
35
  ].freeze
36
36
 
37
37
  class << self
38
+ include Legion::Logging::Helper
39
+
38
40
  def instance
39
41
  @instance ||= new
40
42
  end
@@ -49,9 +51,10 @@ module Legion
49
51
 
50
52
  def read_from_json(file = Legion::Extensions::Llm.config.model_registry_file)
51
53
  data = File.exist?(file) ? File.read(file) : '[]'
52
- models = Legion::JSON.parse(data, symbolize_names: true).map { |model| Model::Info.new(model) }
54
+ models = Legion::JSON.parse(data, symbolize_names: true).map { |model| Model::Info.from_hash(model) }
53
55
  filter_models(models)
54
- rescue Legion::JSON::ParseError
56
+ rescue Legion::JSON::ParseError => e
57
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.models.read_from_json')
55
58
  []
56
59
  end
57
60
 
@@ -92,6 +95,8 @@ module Legion
92
95
  result[:models].concat(provider_class.new(config).list_models)
93
96
  result[:fetched_providers] << provider_class.slug
94
97
  rescue StandardError => e
98
+ handle_exception(e, level: :warn, handled: true,
99
+ operation: 'llm.models.fetch_provider_models')
95
100
  result[:failed] << { name: provider_class.name, slug: provider_class.slug, error: e }
96
101
  end
97
102
  end
@@ -165,14 +170,12 @@ module Legion
165
170
  next [] unless provider_slug
166
171
 
167
172
  (provider_data[:models] || {}).values.map do |model_data|
168
- Model::Info.new(models_dev_model_to_info(model_data, provider_slug, provider_key.to_s))
173
+ Model::Info.from_hash(models_dev_model_to_info(model_data, provider_slug, provider_key.to_s))
169
174
  end
170
175
  end
171
176
  { models: models.reject { |model| model.provider.nil? || model.id.nil? }, fetched: true }
172
177
  rescue StandardError => e
173
- Legion::Extensions::Llm.logger.warn(
174
- "Failed to fetch models.dev (#{e.class}: #{e.message}). Keeping existing."
175
- )
178
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.models.fetch_models_dev')
176
179
  {
177
180
  models: existing_models.select { |model| model.metadata[:source] == 'models.dev' },
178
181
  fetched: false
@@ -264,7 +267,7 @@ module Legion
264
267
  if bedrock_model
265
268
  data = bedrock_model.to_h.merge(id: model_id)
266
269
  data[:context_window] = context_override if context_override
267
- return Model::Info.new(data)
270
+ return Model::Info.from_hash(data)
268
271
  end
269
272
  end
270
273
 
@@ -275,7 +278,7 @@ module Legion
275
278
  return unless gemini_model
276
279
 
277
280
  # Return Gemini's models.dev data but with VertexAI as provider
278
- Model::Info.new(gemini_model.to_h.merge(provider: 'vertexai'))
281
+ Model::Info.from_hash(gemini_model.to_h.merge(provider: 'vertexai'))
279
282
  end
280
283
 
281
284
  def index_by_key(models)
@@ -296,7 +299,7 @@ module Legion
296
299
  data[:metadata] = provider_model.metadata.merge(data[:metadata] || {})
297
300
  data[:capabilities] = (models_dev_model.capabilities + provider_model.capabilities).uniq
298
301
  normalize_embedding_modalities(data)
299
- Model::Info.new(data)
302
+ Model::Info.from_hash(data)
300
303
  end
301
304
 
302
305
  def normalize_embedding_modalities(data)
@@ -458,11 +461,11 @@ module Legion
458
461
  end
459
462
 
460
463
  def by_family(family)
461
- self.class.new(all.select { |m| m.family == family.to_s })
464
+ self.class.new(all.select { |m| m.family.to_s == family.to_s })
462
465
  end
463
466
 
464
467
  def by_provider(provider)
465
- self.class.new(all.select { |m| m.provider == provider.to_s })
468
+ self.class.new(all.select { |m| m.provider.to_s == provider.to_s })
466
469
  end
467
470
 
468
471
  def refresh!(remote_only: false)
@@ -477,8 +480,8 @@ module Legion
477
480
 
478
481
  def find_with_provider(model_id, provider)
479
482
  resolved_id = provider_resolved_model_id(Aliases.resolve(model_id, provider), provider)
480
- all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
481
- all.find { |m| m.id == model_id && m.provider == provider.to_s } ||
483
+ all.find { |m| m.id == resolved_id && m.provider.to_s == provider.to_s } ||
484
+ all.find { |m| m.id == model_id && m.provider.to_s == provider.to_s } ||
482
485
  raise(ModelNotFoundError, "Unknown model: #{model_id} for provider: #{provider}")
483
486
  end
484
487
 
@@ -504,7 +507,7 @@ module Legion
504
507
  return candidates.first if candidates.size == 1
505
508
 
506
509
  candidates.min_by do |model|
507
- index = PROVIDER_PREFERENCE.index(model.provider)
510
+ index = PROVIDER_PREFERENCE.index(model.provider.to_s)
508
511
  index || PROVIDER_PREFERENCE.length
509
512
  end
510
513
  end
@@ -218,14 +218,15 @@ module Legion
218
218
  return arguments if arguments.is_a?(Hash)
219
219
 
220
220
  Legion::JSON.parse(arguments, symbolize_names: false)
221
- rescue Legion::JSON::ParseError
221
+ rescue Legion::JSON::ParseError => e
222
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.parse_tool_arguments')
222
223
  {}
223
224
  end
224
225
 
225
226
  def parse_list_models_response(response, provider, capabilities)
226
227
  response.body.fetch('data', []).map do |model|
227
228
  critical_capabilities = critical_capabilities_for(capabilities, model)
228
- Legion::Extensions::Llm::Model::Info.new(
229
+ Legion::Extensions::Llm::Model::Info.from_hash(
229
230
  id: model.fetch('id'),
230
231
  name: model['id'],
231
232
  provider: provider,
@@ -6,6 +6,7 @@ module Legion
6
6
  # Base class for LLM providers.
7
7
  class Provider
8
8
  include Streaming
9
+ include Legion::Logging::Helper
9
10
 
10
11
  attr_reader :config, :connection
11
12
 
@@ -131,6 +132,7 @@ module Legion
131
132
  response = @connection.get(metadata[:endpoints][:health])
132
133
  metadata.merge(ready: configured? && health_ready?(response.body), health: response.body)
133
134
  rescue StandardError => e
135
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.readiness')
134
136
  metadata.merge(ready: false, health: { error: e.class.name, message: e.message })
135
137
  end
136
138
 
@@ -182,6 +184,112 @@ module Legion
182
184
  nil
183
185
  end
184
186
 
187
+ # ── Model allow-list / deny-list filtering ────────────────────────
188
+
189
+ def model_whitelist
190
+ wl = settings[:model_whitelist] if respond_to?(:settings)
191
+ Array(wl).map { |p| p.to_s.downcase }
192
+ end
193
+
194
+ def model_blacklist
195
+ bl = settings[:model_blacklist] if respond_to?(:settings)
196
+ Array(bl).map { |p| p.to_s.downcase }
197
+ end
198
+
199
+ def model_allowed?(model_name)
200
+ name = model_name.to_s.downcase
201
+ wl = model_whitelist
202
+ bl = model_blacklist
203
+
204
+ return false if wl.any? && wl.none? { |p| name.include?(p) }
205
+ return false if bl.any? && bl.any? { |p| name.include?(p) }
206
+
207
+ true
208
+ end
209
+
210
+ # ── Multi-host base_url resolution ────────────────────────────────
211
+
212
+ def resolve_base_url
213
+ urls = Array(config_base_url)
214
+ @resolve_base_url ||= find_reachable_url(urls) || urls.first
215
+ end
216
+
217
+ def config_base_url
218
+ respond_to?(:settings) ? settings[:base_url] : nil
219
+ end
220
+
221
+ def find_reachable_url(urls)
222
+ urls.each do |url|
223
+ normalized = strip_scheme(url)
224
+ scheme = tls_enabled? ? 'https' : 'http'
225
+ full = "#{scheme}://#{normalized}"
226
+ return full if url_reachable?(full)
227
+ end
228
+ nil
229
+ end
230
+
231
+ def strip_scheme(url)
232
+ url.to_s.sub(%r{^https?://}, '')
233
+ end
234
+
235
+ def url_reachable?(url)
236
+ require 'uri'
237
+ require 'socket'
238
+ uri = URI.parse(url)
239
+ Socket.tcp(uri.host, uri.port, connect_timeout: 1).close
240
+ true
241
+ rescue StandardError
242
+ false
243
+ end
244
+
245
+ def tls_enabled?
246
+ tls = respond_to?(:settings) ? settings[:tls] : nil
247
+ tls.is_a?(Hash) && tls[:enabled] == true
248
+ end
249
+
250
+ # ── Cache helpers with local/shared tier selection ────────────────
251
+
252
+ def cache_local_instance?
253
+ Array(config_base_url).any? do |url|
254
+ host = url.to_s.downcase
255
+ host.include?('localhost') || host.include?('127.0.0.1') || host.include?('::1')
256
+ end
257
+ end
258
+
259
+ def model_cache_get(key)
260
+ return nil unless defined?(Legion::Cache)
261
+
262
+ cache_local_instance? ? local_cache_get(key) : cache_get(key)
263
+ rescue StandardError
264
+ nil
265
+ end
266
+
267
+ def model_cache_set(key, value, ttl:)
268
+ return unless defined?(Legion::Cache)
269
+
270
+ cache_local_instance? ? local_cache_set(key, value, ttl: ttl) : cache_set(key, value, ttl: ttl)
271
+ rescue StandardError => e
272
+ handle_exception(e, level: :debug, handled: true, operation: 'lex.provider.model_cache_set')
273
+ end
274
+
275
+ def model_cache_fetch(key, ttl:, &)
276
+ return yield unless defined?(Legion::Cache)
277
+
278
+ cache_local_instance? ? local_cache_fetch(key, ttl: ttl, &) : cache_fetch(key, ttl: ttl, &)
279
+ rescue StandardError
280
+ yield
281
+ end
282
+
283
+ def cache_instance_key
284
+ if cache_local_instance?
285
+ (respond_to?(:instance_id) ? instance_id : :default).to_s
286
+ else
287
+ require 'digest'
288
+ urls = Array(config_base_url).map { |u| strip_scheme(u).downcase.chomp('/') }.sort
289
+ Digest::SHA256.hexdigest(urls.join('|'))[0, 12]
290
+ end
291
+ end
292
+
185
293
  class << self
186
294
  def name
187
295
  to_s.split('::').last
@@ -223,22 +331,26 @@ module Legion
223
331
  configuration_requirements.all? { |req| config.send(req) }
224
332
  end
225
333
 
334
+ # @deprecated Use the extension registry instead. Will be removed in 1.0.
226
335
  def register(name, provider_class)
227
336
  providers[name.to_sym] = provider_class
228
337
  Legion::Extensions::Llm::Configuration.register_provider_options(provider_class.configuration_options)
229
338
  end
230
339
 
340
+ # @deprecated Use the extension registry instead. Will be removed in 1.0.
231
341
  def resolve(name)
232
342
  return nil if name.nil?
233
343
 
234
344
  providers[name.to_sym]
235
345
  end
236
346
 
347
+ # @deprecated Use the extension registry instead. Will be removed in 1.0.
237
348
  def for(model)
238
349
  model_info = Models.find(model)
239
350
  resolve model_info.provider
240
351
  end
241
352
 
353
+ # @deprecated Use the extension registry instead. Will be removed in 1.0.
242
354
  def providers
243
355
  @providers ||= {}
244
356
  end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ # Builds sanitized lex-llm registry envelopes for provider state.
7
+ # Parameterized by `provider_family` so each lex-llm-* gem can reuse this
8
+ # class without defining its own copy.
9
+ class RegistryEventBuilder
10
+ include Legion::Logging::Helper
11
+
12
+ attr_reader :provider_family
13
+
14
+ def initialize(provider_family:)
15
+ @provider_family = provider_family.to_s.downcase.to_sym
16
+ end
17
+
18
+ def readiness(readiness)
19
+ registry_event_class.public_send(
20
+ readiness[:ready] ? :available : :unavailable,
21
+ provider_offering(readiness),
22
+ runtime: runtime_metadata,
23
+ health: readiness_health(readiness),
24
+ metadata: readiness_metadata(readiness)
25
+ )
26
+ end
27
+
28
+ def model_available(model, readiness:)
29
+ registry_event_class.available(
30
+ model_offering(model),
31
+ runtime: runtime_metadata,
32
+ health: model_health(readiness),
33
+ metadata: model_metadata(model)
34
+ )
35
+ end
36
+
37
+ private
38
+
39
+ def provider_offering(readiness)
40
+ {
41
+ provider_family: provider_family,
42
+ provider_instance: provider_instance,
43
+ transport: :http,
44
+ model: 'provider-readiness',
45
+ usage_type: :inference,
46
+ capabilities: [],
47
+ health: readiness_health(readiness),
48
+ metadata: { lex: extension_sym, provider_readiness: true }
49
+ }
50
+ end
51
+
52
+ def model_offering(model)
53
+ {
54
+ provider_family: provider_family,
55
+ provider_instance: provider_instance,
56
+ transport: :http,
57
+ model: model.id,
58
+ usage_type: usage_type_for(model),
59
+ capabilities: Array(model.capabilities).map(&:to_sym),
60
+ limits: model_limits(model),
61
+ metadata: { lex: extension_sym, model_name: model.name }.compact
62
+ }
63
+ end
64
+
65
+ def readiness_health(readiness)
66
+ health = {
67
+ ready: readiness[:ready] == true,
68
+ status: readiness[:ready] ? :available : :unavailable,
69
+ checked: readiness.dig(:health, :checked) != false
70
+ }
71
+ add_readiness_error(health, readiness[:health])
72
+ end
73
+
74
+ def add_readiness_error(health, source)
75
+ error = source.is_a?(Hash) ? source : {}
76
+ error_class = error[:error] || error['error']
77
+ error_message = error[:message] || error['message']
78
+ health[:error_class] = error_class if error_class
79
+ health[:error] = error_message if error_message
80
+ health
81
+ end
82
+
83
+ def model_health(readiness)
84
+ ready = readiness.fetch(:ready, true) == true
85
+ { ready:, status: ready ? :available : :degraded }
86
+ end
87
+
88
+ def readiness_metadata(readiness)
89
+ {
90
+ extension: extension_sym,
91
+ provider: provider_family,
92
+ configured: readiness[:configured] == true,
93
+ live: readiness[:live] == true
94
+ }
95
+ end
96
+
97
+ def model_metadata(model)
98
+ { extension: extension_sym, provider: provider_family, model_type: model_type_for(model) }
99
+ end
100
+
101
+ def runtime_metadata
102
+ { node: provider_instance }
103
+ end
104
+
105
+ def model_limits(model)
106
+ limits = {}
107
+ limits[:context_window] = model.context_window if model.respond_to?(:context_window)
108
+ limits[:max_output_tokens] = model.max_output_tokens if model.respond_to?(:max_output_tokens)
109
+ limits.compact
110
+ end
111
+
112
+ def usage_type_for(model)
113
+ model_type_for(model) == 'embedding' ? :embedding : :inference
114
+ end
115
+
116
+ def model_type_for(model)
117
+ model.respond_to?(:type) ? model.type : 'chat'
118
+ end
119
+
120
+ def extension_sym
121
+ :"llm_#{provider_family}"
122
+ end
123
+
124
+ def provider_instance
125
+ configured_node = (::Legion::Settings.dig(:node, :canonical_name) if defined?(::Legion::Settings))
126
+ value = configured_node.to_s.strip
127
+ value.empty? ? provider_family : value.to_sym
128
+ rescue StandardError => e
129
+ handle_exception(e, level: :debug, handled: true,
130
+ operation: "#{provider_family}.registry.provider_instance")
131
+ provider_family
132
+ end
133
+
134
+ def registry_event_class
135
+ ::Legion::Extensions::Llm::Routing::RegistryEvent
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ # Best-effort publisher for LLM provider availability events.
7
+ # Parameterized by `provider_family` so each lex-llm-* gem can reuse this
8
+ # class without defining its own copy.
9
+ class RegistryPublisher
10
+ include Legion::Logging::Helper
11
+
12
+ attr_reader :provider_family
13
+
14
+ def initialize(provider_family:, builder: nil)
15
+ @provider_family = provider_family.to_s.downcase.to_sym
16
+ @builder = builder || RegistryEventBuilder.new(provider_family: @provider_family)
17
+ end
18
+
19
+ def app_id
20
+ "lex-llm-#{provider_family}"
21
+ end
22
+
23
+ def publish_readiness_async(readiness)
24
+ log.info { "publishing readiness event to llm.registry for #{provider_family}" }
25
+ schedule { publish_event(@builder.readiness(readiness)) }
26
+ end
27
+
28
+ def publish_models_async(models, readiness:)
29
+ log.info { "publishing #{Array(models).size} model event(s) to llm.registry for #{provider_family}" }
30
+ schedule do
31
+ Array(models).each do |model|
32
+ publish_event(@builder.model_available(model, readiness:))
33
+ end
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def schedule(&)
40
+ return false unless publishing_available?
41
+
42
+ Thread.new do
43
+ Thread.current.abort_on_exception = false
44
+ yield
45
+ rescue StandardError => e
46
+ handle_exception(e, level: :debug, handled: true,
47
+ operation: "#{provider_family}.registry.schedule_thread")
48
+ end
49
+ rescue StandardError => e
50
+ handle_exception(e, level: :debug, handled: true,
51
+ operation: "#{provider_family}.registry.schedule")
52
+ false
53
+ end
54
+
55
+ def publish_event(event)
56
+ return false unless publishing_available?
57
+
58
+ message_class.new(event:, provider_family: provider_family, app_id: app_id).publish(spool: false)
59
+ rescue StandardError => e
60
+ handle_exception(e, level: :warn, handled: true,
61
+ operation: "#{provider_family}.registry.publish_event")
62
+ false
63
+ end
64
+
65
+ def publishing_available?
66
+ return false unless registry_event_available?
67
+ return false unless transport_message_available?
68
+ return true unless defined?(::Legion::Transport::Connection)
69
+ return true unless ::Legion::Transport::Connection.respond_to?(:session_open?)
70
+
71
+ ::Legion::Transport::Connection.session_open?
72
+ rescue StandardError => e
73
+ handle_exception(e, level: :debug, handled: true,
74
+ operation: "#{provider_family}.registry.publishing_available?")
75
+ false
76
+ end
77
+
78
+ def registry_event_available?
79
+ defined?(::Legion::Extensions::Llm::Routing::RegistryEvent)
80
+ end
81
+
82
+ def transport_message_available?
83
+ return true if message_class_defined?
84
+ return false unless defined?(::Legion::Transport::Message) && defined?(::Legion::Transport::Exchange)
85
+
86
+ require 'legion/extensions/llm/transport/messages/registry_event'
87
+ message_class_defined?
88
+ rescue LoadError => e
89
+ handle_exception(e, level: :debug, handled: true,
90
+ operation: "#{provider_family}.registry.transport_load")
91
+ false
92
+ end
93
+
94
+ def message_class_defined?
95
+ defined?(::Legion::Extensions::Llm::Transport::Messages::RegistryEvent)
96
+ end
97
+
98
+ def message_class
99
+ ::Legion::Extensions::Llm::Transport::Messages::RegistryEvent
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -91,8 +91,8 @@ module Legion
91
91
  buffer << chunk
92
92
  error_data = Legion::JSON.parse(buffer, symbolize_names: false)
93
93
  handle_parsed_error(error_data, env)
94
- rescue Legion::JSON::ParseError
95
- Legion::Extensions::Llm.logger.debug { "Accumulating error chunk: #{chunk}" }
94
+ rescue Legion::JSON::ParseError => e
95
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.streaming.handle_failed_response')
96
96
  end
97
97
 
98
98
  def handle_sse(chunk, parser, env, &)
@@ -112,7 +112,7 @@ module Legion
112
112
 
113
113
  handle_parsed_error(parsed, env)
114
114
  rescue Legion::JSON::ParseError => e
115
- Legion::Extensions::Llm.logger.debug { "Failed to parse data chunk: #{e.message}" }
115
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.streaming.handle_data')
116
116
  end
117
117
 
118
118
  def handle_error_event(data, env)
@@ -123,7 +123,7 @@ module Legion
123
123
  error_data = Legion::JSON.parse(data, symbolize_names: false)
124
124
  [500, error_data['message'] || 'Unknown streaming error']
125
125
  rescue Legion::JSON::ParseError => e
126
- Legion::Extensions::Llm.logger.debug { "Failed to parse streaming error: #{e.message}" }
126
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.streaming.parse_streaming_error')
127
127
  [500, "Failed to parse error: #{data}"]
128
128
  end
129
129
 
@@ -133,11 +133,11 @@ module Legion
133
133
  ErrorMiddleware.parse_error(provider: self, response: error_response)
134
134
  end
135
135
 
136
- def parse_error_from_json(data, env, error_message)
136
+ def parse_error_from_json(data, env, _error_message)
137
137
  parsed_data = Legion::JSON.parse(data, symbolize_names: false)
138
138
  handle_parsed_error(parsed_data, env)
139
139
  rescue Legion::JSON::ParseError => e
140
- Legion::Extensions::Llm.logger.debug { "#{error_message}: #{e.message}" }
140
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.streaming.parse_error_from_json')
141
141
  end
142
142
 
143
143
  def build_stream_error_response(parsed_data, env, status)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(Legion::Transport::Exchange)
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Llm
8
+ module Transport
9
+ module Exchanges
10
+ # Shared topic exchange for LLM provider availability events.
11
+ # All lex-llm-* providers publish to the same `llm.registry` exchange.
12
+ class LlmRegistry < ::Legion::Transport::Exchange
13
+ def exchange_name
14
+ 'llm.registry'
15
+ end
16
+
17
+ def default_type
18
+ 'topic'
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ return unless defined?(Legion::Transport::Message)
4
+
5
+ require_relative '../exchanges/llm_registry'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Llm
10
+ module Transport
11
+ module Messages
12
+ # Publishes lex-llm RegistryEvent envelopes to the shared llm.registry exchange.
13
+ # Accepts a `provider_family` for constructing the app_id and routing key.
14
+ class RegistryEvent < ::Legion::Transport::Message
15
+ def initialize(event:, provider_family: nil, **options)
16
+ @provider_family = provider_family
17
+ super(**event.to_h.merge(options))
18
+ end
19
+
20
+ def exchange
21
+ Exchanges::LlmRegistry
22
+ end
23
+
24
+ def routing_key
25
+ @options[:routing_key] || "llm.registry.#{@options.fetch(:event_type)}"
26
+ end
27
+
28
+ def type
29
+ 'llm.registry.event'
30
+ end
31
+
32
+ def app_id
33
+ @options[:app_id] || "lex-llm-#{@provider_family || 'unknown'}"
34
+ end
35
+
36
+ def persistent # rubocop:disable Naming/PredicateMethod
37
+ false
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.1.7'
6
+ VERSION = '0.1.9'
7
7
  end
8
8
  end
9
9
  end
@@ -8,6 +8,7 @@ require 'faraday'
8
8
  require 'faraday/multipart'
9
9
  require 'faraday/retry'
10
10
  require 'legion/json'
11
+ require 'legion/logging'
11
12
  require 'logger'
12
13
  require 'marcel'
13
14
  require 'ruby_llm/schema'
@@ -30,6 +31,8 @@ module Legion
30
31
  'ui' => 'UI'
31
32
  )
32
33
  loader.ignore("#{__dir__}/llm/version.rb")
34
+ loader.ignore("#{__dir__}/llm/transport/exchanges")
35
+ loader.ignore("#{__dir__}/llm/transport/messages")
33
36
  loader.push_dir("#{__dir__}/llm", namespace: self)
34
37
  loader.setup
35
38
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -225,6 +225,8 @@ files:
225
225
  - lib/legion/extensions/llm/provider.rb
226
226
  - lib/legion/extensions/llm/provider/open_ai_compatible.rb
227
227
  - lib/legion/extensions/llm/provider_settings.rb
228
+ - lib/legion/extensions/llm/registry_event_builder.rb
229
+ - lib/legion/extensions/llm/registry_publisher.rb
228
230
  - lib/legion/extensions/llm/routing.rb
229
231
  - lib/legion/extensions/llm/routing/lane_key.rb
230
232
  - lib/legion/extensions/llm/routing/model_offering.rb
@@ -237,7 +239,9 @@ files:
237
239
  - lib/legion/extensions/llm/tool.rb
238
240
  - lib/legion/extensions/llm/tool_call.rb
239
241
  - lib/legion/extensions/llm/transcription.rb
242
+ - lib/legion/extensions/llm/transport/exchanges/llm_registry.rb
240
243
  - lib/legion/extensions/llm/transport/fleet_lane.rb
244
+ - lib/legion/extensions/llm/transport/messages/registry_event.rb
241
245
  - lib/legion/extensions/llm/utils.rb
242
246
  - lib/legion/extensions/llm/version.rb
243
247
  homepage: https://github.com/LegionIO/lex-llm