lex-llm 0.1.8 → 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: d41cf2984b04621d4e0c2a7fa84a7236361a561570b9944358631a76e6699ac9
4
- data.tar.gz: c84f98866b4f313d240f964d691b6c170d8635f6275ca8c0150b54d6e2d286cf
3
+ metadata.gz: 0ca7422981b9d63c85ebe052990cd12af77c9e625ce7597d67e8161418a94ea3
4
+ data.tar.gz: 1cd31a58aa6bc9f35a9e8d45c1bed00c05b7dbdcfaaddb81bb29f644787a7703
5
5
  SHA512:
6
- metadata.gz: a4516de1ebcab041beeabaf718ea11ed3445a943a3c4bbe388936622690001dec8606a8c26d5850f123950ac1d4a09361c4513a8387615e86cc5ef99af133860
7
- data.tar.gz: 3d10adbbb6684df81ac7090a4a7c0ebd179803069e74b80e13a626b1cd93d67e9ab955bb5259a2ab638373aaf786217b738536478893fe8983366a2f29ee6e99
6
+ metadata.gz: d0de80f09c6820c95b51297bc3a6b0bdebfdaaedb007668c635b670c45668e80c1ed4a9eb0c2cff5db48aef602c1aa4e9f325b734ed63f661fbec4248589aa26
7
+ data.tar.gz: 8bfed0a1cec4ec06062c6797afed865f7fec9757b7906022dfd04bd9650b2b3bd5bc67c6cda65f3464bc3f8da1c0a59543d573ae30231bcc2874b307a0fb6e85
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
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
+
3
16
  ## 0.1.8 - 2026-04-30
4
17
 
5
18
  - Audit all rescue blocks for handle_exception compliance
@@ -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
@@ -51,7 +51,7 @@ module Legion
51
51
 
52
52
  def read_from_json(file = Legion::Extensions::Llm.config.model_registry_file)
53
53
  data = File.exist?(file) ? File.read(file) : '[]'
54
- 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) }
55
55
  filter_models(models)
56
56
  rescue Legion::JSON::ParseError => e
57
57
  handle_exception(e, level: :warn, handled: true, operation: 'llm.models.read_from_json')
@@ -170,7 +170,7 @@ module Legion
170
170
  next [] unless provider_slug
171
171
 
172
172
  (provider_data[:models] || {}).values.map do |model_data|
173
- 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))
174
174
  end
175
175
  end
176
176
  { models: models.reject { |model| model.provider.nil? || model.id.nil? }, fetched: true }
@@ -267,7 +267,7 @@ module Legion
267
267
  if bedrock_model
268
268
  data = bedrock_model.to_h.merge(id: model_id)
269
269
  data[:context_window] = context_override if context_override
270
- return Model::Info.new(data)
270
+ return Model::Info.from_hash(data)
271
271
  end
272
272
  end
273
273
 
@@ -278,7 +278,7 @@ module Legion
278
278
  return unless gemini_model
279
279
 
280
280
  # Return Gemini's models.dev data but with VertexAI as provider
281
- Model::Info.new(gemini_model.to_h.merge(provider: 'vertexai'))
281
+ Model::Info.from_hash(gemini_model.to_h.merge(provider: 'vertexai'))
282
282
  end
283
283
 
284
284
  def index_by_key(models)
@@ -299,7 +299,7 @@ module Legion
299
299
  data[:metadata] = provider_model.metadata.merge(data[:metadata] || {})
300
300
  data[:capabilities] = (models_dev_model.capabilities + provider_model.capabilities).uniq
301
301
  normalize_embedding_modalities(data)
302
- Model::Info.new(data)
302
+ Model::Info.from_hash(data)
303
303
  end
304
304
 
305
305
  def normalize_embedding_modalities(data)
@@ -461,11 +461,11 @@ module Legion
461
461
  end
462
462
 
463
463
  def by_family(family)
464
- 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 })
465
465
  end
466
466
 
467
467
  def by_provider(provider)
468
- 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 })
469
469
  end
470
470
 
471
471
  def refresh!(remote_only: false)
@@ -480,8 +480,8 @@ module Legion
480
480
 
481
481
  def find_with_provider(model_id, provider)
482
482
  resolved_id = provider_resolved_model_id(Aliases.resolve(model_id, provider), provider)
483
- all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
484
- 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 } ||
485
485
  raise(ModelNotFoundError, "Unknown model: #{model_id} for provider: #{provider}")
486
486
  end
487
487
 
@@ -507,7 +507,7 @@ module Legion
507
507
  return candidates.first if candidates.size == 1
508
508
 
509
509
  candidates.min_by do |model|
510
- index = PROVIDER_PREFERENCE.index(model.provider)
510
+ index = PROVIDER_PREFERENCE.index(model.provider.to_s)
511
511
  index || PROVIDER_PREFERENCE.length
512
512
  end
513
513
  end
@@ -226,7 +226,7 @@ module Legion
226
226
  def parse_list_models_response(response, provider, capabilities)
227
227
  response.body.fetch('data', []).map do |model|
228
228
  critical_capabilities = critical_capabilities_for(capabilities, model)
229
- Legion::Extensions::Llm::Model::Info.new(
229
+ Legion::Extensions::Llm::Model::Info.from_hash(
230
230
  id: model.fetch('id'),
231
231
  name: model['id'],
232
232
  provider: provider,
@@ -184,6 +184,112 @@ module Legion
184
184
  nil
185
185
  end
186
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
+
187
293
  class << self
188
294
  def name
189
295
  to_s.split('::').last
@@ -225,22 +331,26 @@ module Legion
225
331
  configuration_requirements.all? { |req| config.send(req) }
226
332
  end
227
333
 
334
+ # @deprecated Use the extension registry instead. Will be removed in 1.0.
228
335
  def register(name, provider_class)
229
336
  providers[name.to_sym] = provider_class
230
337
  Legion::Extensions::Llm::Configuration.register_provider_options(provider_class.configuration_options)
231
338
  end
232
339
 
340
+ # @deprecated Use the extension registry instead. Will be removed in 1.0.
233
341
  def resolve(name)
234
342
  return nil if name.nil?
235
343
 
236
344
  providers[name.to_sym]
237
345
  end
238
346
 
347
+ # @deprecated Use the extension registry instead. Will be removed in 1.0.
239
348
  def for(model)
240
349
  model_info = Models.find(model)
241
350
  resolve model_info.provider
242
351
  end
243
352
 
353
+ # @deprecated Use the extension registry instead. Will be removed in 1.0.
244
354
  def providers
245
355
  @providers ||= {}
246
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
@@ -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.8'
6
+ VERSION = '0.1.9'
7
7
  end
8
8
  end
9
9
  end
@@ -31,6 +31,8 @@ module Legion
31
31
  'ui' => 'UI'
32
32
  )
33
33
  loader.ignore("#{__dir__}/llm/version.rb")
34
+ loader.ignore("#{__dir__}/llm/transport/exchanges")
35
+ loader.ignore("#{__dir__}/llm/transport/messages")
34
36
  loader.push_dir("#{__dir__}/llm", namespace: self)
35
37
  loader.setup
36
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.8
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