lex-llm 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/lib/legion/extensions/llm/model/info.rb +201 -63
- data/lib/legion/extensions/llm/models.rb +10 -10
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +1 -1
- data/lib/legion/extensions/llm/provider.rb +118 -0
- data/lib/legion/extensions/llm/registry_event_builder.rb +140 -0
- data/lib/legion/extensions/llm/registry_publisher.rb +104 -0
- data/lib/legion/extensions/llm/transport/exchanges/llm_registry.rb +25 -0
- data/lib/legion/extensions/llm/transport/messages/registry_event.rb +44 -0
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +2 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e89cf3b81ab7c122b0a4ffd99da2dda0cdc7c79258c43aa06291b8008985815a
|
|
4
|
+
data.tar.gz: 8a7e3da631d4595fd4a57de32a6bcdd13004313a00a653bf2b0fd347e85bbd20
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 11e4dac6953ab61572d539c2e5c7cea634ab96388ba654392ea4b64365bab694f0e420acaf0a098ced97613240b89122fc3e8b5b186e4e78b77d37feca8ffbfd
|
|
7
|
+
data.tar.gz: cb0224a1f4130f2783b17464cc182b2b01b6feee21e35246b99a73b568e68d64f1191b39d59a0f82d333cac0df9b15ab86e710269b44141d0039a0997b008340
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 - 2026-04-30
|
|
4
|
+
|
|
5
|
+
- Promote ModelInfo Data.define value object with immutable fields: instance, parameter_count, parameter_size, quantization, size_bytes, modalities_input, modalities_output
|
|
6
|
+
- Formalize provider contract: model_allowed? whitelist/blacklist filtering, multi-host base_url resolution with TLS awareness and reachability probing, normalize_url for consistent endpoint formatting
|
|
7
|
+
- Add cache tier selection helpers: cache_local_instance?, model_cache_get/set/fetch, cache_instance_key for local vs shared cache routing
|
|
8
|
+
- Add shared transport classes and RegistryPublisher/RegistryEventBuilder parameterized by provider_family for all lex-llm-* gems
|
|
9
|
+
- Deprecate Provider.register, .resolve, .for, .providers in favor of the extension registry
|
|
10
|
+
|
|
11
|
+
## 0.1.9 - 2026-04-30
|
|
12
|
+
|
|
13
|
+
- 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
|
|
14
|
+
- Add Model::Info.from_hash factory for backward-compatible construction from legacy hash format
|
|
15
|
+
- Add backward-compatible accessors on Model::Info for context_window, max_output_tokens, created_at, knowledge_cutoff, modalities, pricing, type, and legacy capability predicates
|
|
16
|
+
- Add model_allowed? to base Provider with whitelist/blacklist filtering from settings
|
|
17
|
+
- Add multi-host base_url resolution with TLS awareness and reachability probing
|
|
18
|
+
- Add cache tier selection helpers: cache_local_instance?, model_cache_get/set/fetch, cache_instance_key for local vs shared cache routing
|
|
19
|
+
- Add shared transport classes for llm.registry exchange and registry event messages (guarded by defined? for optional legion-transport)
|
|
20
|
+
- Add shared RegistryPublisher parameterized by provider_family for all lex-llm-* gems
|
|
21
|
+
- Add shared RegistryEventBuilder parameterized by provider_family for all lex-llm-* gems
|
|
22
|
+
- Mark Provider.register, .resolve, .for, .providers with @deprecated annotations for future removal in favor of the extension registry
|
|
23
|
+
|
|
3
24
|
## 0.1.8 - 2026-04-30
|
|
4
25
|
|
|
5
26
|
- 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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
50
|
-
|
|
59
|
+
def context_window
|
|
60
|
+
context_length || metadata[:context_window]
|
|
51
61
|
end
|
|
52
62
|
|
|
53
|
-
def
|
|
54
|
-
|
|
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
|
|
63
|
-
|
|
71
|
+
def created_at
|
|
72
|
+
metadata[:created_at]
|
|
64
73
|
end
|
|
65
74
|
|
|
66
|
-
def
|
|
67
|
-
|
|
75
|
+
def knowledge_cutoff
|
|
76
|
+
metadata[:knowledge_cutoff]
|
|
68
77
|
end
|
|
69
78
|
|
|
70
|
-
def
|
|
71
|
-
|
|
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
|
|
83
|
-
|
|
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 =
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,120 @@ 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
|
+
return nil if urls.empty?
|
|
215
|
+
|
|
216
|
+
@resolve_base_url ||= find_reachable_url(urls) || normalize_url(urls.first)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def config_base_url
|
|
220
|
+
respond_to?(:settings) ? settings[:base_url] : nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def normalize_url(url)
|
|
224
|
+
raw = url.to_s.strip
|
|
225
|
+
return raw if raw.match?(%r{^https?://})
|
|
226
|
+
|
|
227
|
+
scheme = tls_enabled? ? 'https' : 'http'
|
|
228
|
+
"#{scheme}://#{raw}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def find_reachable_url(urls)
|
|
232
|
+
urls.each do |url|
|
|
233
|
+
full = normalize_url(url)
|
|
234
|
+
return full if url_reachable?(full)
|
|
235
|
+
end
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def strip_scheme(url)
|
|
240
|
+
url.to_s.sub(%r{^https?://}, '')
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def url_reachable?(url)
|
|
244
|
+
require 'uri'
|
|
245
|
+
require 'socket'
|
|
246
|
+
uri = URI.parse(url)
|
|
247
|
+
Socket.tcp(uri.host, uri.port, connect_timeout: 1).close
|
|
248
|
+
true
|
|
249
|
+
rescue StandardError
|
|
250
|
+
false
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def tls_enabled?
|
|
254
|
+
tls = respond_to?(:settings) ? settings[:tls] : nil
|
|
255
|
+
tls.is_a?(Hash) && tls[:enabled] == true
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# ── Cache helpers with local/shared tier selection ────────────────
|
|
259
|
+
|
|
260
|
+
def cache_local_instance?
|
|
261
|
+
Array(config_base_url).any? do |url|
|
|
262
|
+
host = url.to_s.downcase
|
|
263
|
+
host.include?('localhost') || host.include?('127.0.0.1') || host.include?('::1')
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def model_cache_get(key)
|
|
268
|
+
return nil unless defined?(Legion::Cache)
|
|
269
|
+
|
|
270
|
+
cache_local_instance? ? local_cache_get(key) : cache_get(key)
|
|
271
|
+
rescue StandardError
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def model_cache_set(key, value, ttl:)
|
|
276
|
+
return unless defined?(Legion::Cache)
|
|
277
|
+
|
|
278
|
+
cache_local_instance? ? local_cache_set(key, value, ttl: ttl) : cache_set(key, value, ttl: ttl)
|
|
279
|
+
rescue StandardError => e
|
|
280
|
+
handle_exception(e, level: :debug, handled: true, operation: 'lex.provider.model_cache_set')
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def model_cache_fetch(key, ttl:, &)
|
|
284
|
+
return yield unless defined?(Legion::Cache)
|
|
285
|
+
|
|
286
|
+
cache_local_instance? ? local_cache_fetch(key, ttl: ttl, &) : cache_fetch(key, ttl: ttl, &)
|
|
287
|
+
rescue StandardError
|
|
288
|
+
yield
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def cache_instance_key
|
|
292
|
+
if cache_local_instance?
|
|
293
|
+
(respond_to?(:instance_id) ? instance_id : :default).to_s
|
|
294
|
+
else
|
|
295
|
+
require 'digest'
|
|
296
|
+
urls = Array(config_base_url).map { |u| strip_scheme(u).downcase.chomp('/') }.sort
|
|
297
|
+
Digest::SHA256.hexdigest(urls.join('|'))[0, 12]
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
187
301
|
class << self
|
|
188
302
|
def name
|
|
189
303
|
to_s.split('::').last
|
|
@@ -225,22 +339,26 @@ module Legion
|
|
|
225
339
|
configuration_requirements.all? { |req| config.send(req) }
|
|
226
340
|
end
|
|
227
341
|
|
|
342
|
+
# @deprecated Use the extension registry instead. Will be removed in 1.0.
|
|
228
343
|
def register(name, provider_class)
|
|
229
344
|
providers[name.to_sym] = provider_class
|
|
230
345
|
Legion::Extensions::Llm::Configuration.register_provider_options(provider_class.configuration_options)
|
|
231
346
|
end
|
|
232
347
|
|
|
348
|
+
# @deprecated Use the extension registry instead. Will be removed in 1.0.
|
|
233
349
|
def resolve(name)
|
|
234
350
|
return nil if name.nil?
|
|
235
351
|
|
|
236
352
|
providers[name.to_sym]
|
|
237
353
|
end
|
|
238
354
|
|
|
355
|
+
# @deprecated Use the extension registry instead. Will be removed in 1.0.
|
|
239
356
|
def for(model)
|
|
240
357
|
model_info = Models.find(model)
|
|
241
358
|
resolve model_info.provider
|
|
242
359
|
end
|
|
243
360
|
|
|
361
|
+
# @deprecated Use the extension registry instead. Will be removed in 1.0.
|
|
244
362
|
def providers
|
|
245
363
|
@providers ||= {}
|
|
246
364
|
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
|
|
@@ -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.
|
|
4
|
+
version: 0.2.0
|
|
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
|