lex-llm 0.5.3 → 0.6.2
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 +29 -0
- data/lib/legion/extensions/llm/capabilities.rb +69 -0
- data/lib/legion/extensions/llm/capability_policy.rb +27 -18
- data/lib/legion/extensions/llm/credential_sources.rb +6 -6
- data/lib/legion/extensions/llm/error.rb +15 -0
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -1
- data/lib/legion/extensions/llm/fleet/settings.rb +2 -2
- data/lib/legion/extensions/llm/inventory/capabilities.rb +40 -0
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +105 -0
- data/lib/legion/extensions/llm/model/info.rb +12 -1
- data/lib/legion/extensions/llm/provider.rb +265 -7
- data/lib/legion/extensions/llm/registry_event_builder.rb +4 -3
- data/lib/legion/extensions/llm/registry_publisher.rb +11 -8
- data/lib/legion/extensions/llm/routing/model_offering.rb +2 -9
- data/lib/legion/extensions/llm/taxonomies.rb +14 -0
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +6 -0
- data/spec/legion/extensions/llm/capabilities_spec.rb +50 -0
- data/spec/legion/extensions/llm/capability_policy_spec.rb +28 -5
- data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +43 -0
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +209 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +1 -1
- data/spec/legion/extensions/llm/provider_spec.rb +76 -0
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +3 -2
- data/spec/legion/extensions/llm/taxonomies_spec.rb +28 -0
- metadata +9 -1
|
@@ -10,6 +10,10 @@ module Legion
|
|
|
10
10
|
@data = hash.transform_keys(&:to_sym)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
def to_h
|
|
14
|
+
@data.dup
|
|
15
|
+
end
|
|
16
|
+
|
|
13
17
|
def respond_to_missing?(name, include_private = false)
|
|
14
18
|
@data.key?(name.to_sym) || super
|
|
15
19
|
end
|
|
@@ -31,6 +35,46 @@ module Legion
|
|
|
31
35
|
include Legion::Cache::Helper
|
|
32
36
|
|
|
33
37
|
MODEL_DETAIL_CACHE_SCHEMA_VERSION = 2
|
|
38
|
+
CAPABILITY_CONFIG_KEYS = %i[
|
|
39
|
+
capabilities
|
|
40
|
+
enable_completion
|
|
41
|
+
enable_embedding
|
|
42
|
+
enable_embeddings
|
|
43
|
+
enable_streaming
|
|
44
|
+
enable_tools
|
|
45
|
+
enable_functions
|
|
46
|
+
enable_function_calling
|
|
47
|
+
enable_thinking
|
|
48
|
+
enable_reasoning
|
|
49
|
+
enable_vision
|
|
50
|
+
enable_structured_output
|
|
51
|
+
enable_moderation
|
|
52
|
+
enable_image
|
|
53
|
+
enable_images
|
|
54
|
+
enable_image_generation
|
|
55
|
+
enable_audio_transcription
|
|
56
|
+
enable_audio_speech
|
|
57
|
+
enable_audio_generation
|
|
58
|
+
completion_flag
|
|
59
|
+
embedding_flag
|
|
60
|
+
embeddings_flag
|
|
61
|
+
streaming_flag
|
|
62
|
+
tool_flag
|
|
63
|
+
tools_flag
|
|
64
|
+
functions_flag
|
|
65
|
+
function_calling_flag
|
|
66
|
+
thinking_flag
|
|
67
|
+
reasoning_flag
|
|
68
|
+
vision_flag
|
|
69
|
+
structured_output_flag
|
|
70
|
+
moderation_flag
|
|
71
|
+
image_flag
|
|
72
|
+
images_flag
|
|
73
|
+
image_generation_flag
|
|
74
|
+
audio_transcription_flag
|
|
75
|
+
audio_speech_flag
|
|
76
|
+
audio_generation_flag
|
|
77
|
+
].freeze
|
|
34
78
|
|
|
35
79
|
attr_reader :config, :connection
|
|
36
80
|
|
|
@@ -96,6 +140,7 @@ module Legion
|
|
|
96
140
|
|
|
97
141
|
def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil,
|
|
98
142
|
tool_prefs: nil, &)
|
|
143
|
+
enforce_model_allowed!(model)
|
|
99
144
|
normalized_temperature = maybe_normalize_temperature(temperature, model)
|
|
100
145
|
log_provider_request(
|
|
101
146
|
messages: messages,
|
|
@@ -144,11 +189,14 @@ module Legion
|
|
|
144
189
|
|
|
145
190
|
provider_health = health(live:)
|
|
146
191
|
@cached_offerings = Array(list_models(live:, **filters)).filter_map do |model|
|
|
192
|
+
publish_discovered_model_to_registry(model, provider_health:, live:)
|
|
147
193
|
next unless model_matches_filters?(model, filters)
|
|
148
194
|
next unless model_allowed?(model.id)
|
|
149
195
|
|
|
196
|
+
log.debug("[#{slug}] instance=#{provider_instance_id} action=model_discovered model=#{model.id} family=#{model.family}")
|
|
150
197
|
offering_from_model(model, health: provider_health)
|
|
151
198
|
end
|
|
199
|
+
log.info("[#{slug}] instance=#{provider_instance_id} action=discover_complete model_count=#{Array(@cached_offerings).size}")
|
|
152
200
|
@cached_offerings
|
|
153
201
|
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
154
202
|
log.warn("[#{slug}] instance=#{provider_instance_id} unreachable: #{e.message}")
|
|
@@ -157,17 +205,45 @@ module Legion
|
|
|
157
205
|
[]
|
|
158
206
|
end
|
|
159
207
|
|
|
208
|
+
def publish_discovered_model_to_registry(model, provider_health:, live:)
|
|
209
|
+
publisher = discovery_registry_publisher
|
|
210
|
+
return unless publisher.respond_to?(:publish_models_async)
|
|
211
|
+
|
|
212
|
+
publisher.publish_models_async([model], readiness: discovery_registry_readiness(provider_health, live:))
|
|
213
|
+
rescue StandardError => e
|
|
214
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.publish_discovered_model')
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def discovery_registry_publisher
|
|
218
|
+
return unless self.class.respond_to?(:registry_publisher)
|
|
219
|
+
|
|
220
|
+
self.class.registry_publisher
|
|
221
|
+
rescue StandardError
|
|
222
|
+
nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def discovery_registry_readiness(provider_health, live:)
|
|
226
|
+
{
|
|
227
|
+
provider: slug.to_sym,
|
|
228
|
+
configured: configured?,
|
|
229
|
+
ready: provider_health[:ready] == true,
|
|
230
|
+
live: live,
|
|
231
|
+
health: provider_health
|
|
232
|
+
}
|
|
233
|
+
end
|
|
234
|
+
|
|
160
235
|
def health(live: false)
|
|
161
236
|
readiness_data = readiness(live:)
|
|
162
237
|
raw_health = readiness_data[:health] || readiness_data['health'] || {}
|
|
163
238
|
status = health_status(readiness_data, raw_health)
|
|
239
|
+
latency_ms = (raw_health[:latency_ms] || raw_health['latency_ms'] if raw_health.is_a?(Hash))
|
|
164
240
|
{
|
|
165
241
|
provider: slug.to_sym,
|
|
166
242
|
instance_id: provider_instance_id,
|
|
167
243
|
status:,
|
|
168
244
|
ready: readiness_data[:ready] == true || readiness_data['ready'] == true,
|
|
169
245
|
circuit_state: status == 'healthy' ? 'closed' : 'open',
|
|
170
|
-
latency_ms:
|
|
246
|
+
latency_ms: latency_ms,
|
|
171
247
|
raw: raw_health
|
|
172
248
|
}.compact
|
|
173
249
|
rescue StandardError => e
|
|
@@ -184,6 +260,7 @@ module Legion
|
|
|
184
260
|
end
|
|
185
261
|
|
|
186
262
|
def embed(text:, model:, dimensions: nil, params: {}, headers: {})
|
|
263
|
+
enforce_model_allowed!(model)
|
|
187
264
|
payload = Utils.deep_merge(render_embedding_payload(text, model:, dimensions:), params)
|
|
188
265
|
response = @connection.post(embedding_url(model:), payload) do |req|
|
|
189
266
|
req.headers = headers.merge(req.headers) unless headers.empty?
|
|
@@ -192,12 +269,14 @@ module Legion
|
|
|
192
269
|
end
|
|
193
270
|
|
|
194
271
|
def moderate(input, model:)
|
|
272
|
+
enforce_model_allowed!(model)
|
|
195
273
|
payload = render_moderation_payload(input, model:)
|
|
196
274
|
response = @connection.post moderation_url, payload
|
|
197
275
|
parse_moderation_response(response, model:)
|
|
198
276
|
end
|
|
199
277
|
|
|
200
278
|
def paint(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Metrics/ParameterLists
|
|
279
|
+
enforce_model_allowed!(model)
|
|
201
280
|
validate_paint_inputs!(with:, mask:)
|
|
202
281
|
payload = render_image_payload(prompt, model:, size:, with:, mask:, params:)
|
|
203
282
|
response = @connection.post images_url(with:, mask:), payload
|
|
@@ -334,20 +413,44 @@ module Legion
|
|
|
334
413
|
|
|
335
414
|
# ── Model allow-list / deny-list filtering ────────────────────────
|
|
336
415
|
|
|
416
|
+
# Resolve model_whitelist with specificity cascade:
|
|
417
|
+
# 1. Instance-level (config.model_whitelist — extensions.llm.<provider>.instances.<id>.model_whitelist)
|
|
418
|
+
# 2. Provider-level (extensions.llm.<provider>.model_whitelist)
|
|
419
|
+
# 3. Global (extensions.llm.model_whitelist)
|
|
420
|
+
# Returns the first non-nil, non-empty value found.
|
|
337
421
|
def model_whitelist
|
|
338
422
|
wl = config.model_whitelist if config.respond_to?(:model_whitelist)
|
|
339
|
-
wl ||=
|
|
423
|
+
wl ||= instance_setting(:model_whitelist)
|
|
340
424
|
wl ||= runtime_provider_setting(:model_whitelist)
|
|
425
|
+
wl ||= global_llm_setting(:model_whitelist)
|
|
341
426
|
Array(wl).map { |p| p.to_s.downcase }
|
|
342
427
|
end
|
|
343
428
|
|
|
429
|
+
# Resolve model_blacklist with the same specificity cascade as model_whitelist.
|
|
344
430
|
def model_blacklist
|
|
345
431
|
bl = config.model_blacklist if config.respond_to?(:model_blacklist)
|
|
346
|
-
bl ||=
|
|
432
|
+
bl ||= instance_setting(:model_blacklist)
|
|
347
433
|
bl ||= runtime_provider_setting(:model_blacklist)
|
|
434
|
+
bl ||= global_llm_setting(:model_blacklist)
|
|
348
435
|
Array(bl).map { |p| p.to_s.downcase }
|
|
349
436
|
end
|
|
350
437
|
|
|
438
|
+
# Pull a setting from the instance-level settings hash (if available),
|
|
439
|
+
# distinct from the config object which is a HashConfig wrapper.
|
|
440
|
+
def instance_setting(key)
|
|
441
|
+
config_hash =
|
|
442
|
+
if instance_variable_defined?(:@settings)
|
|
443
|
+
@settings
|
|
444
|
+
elsif respond_to?(:settings)
|
|
445
|
+
settings
|
|
446
|
+
else
|
|
447
|
+
config
|
|
448
|
+
end
|
|
449
|
+
config_hash = config_hash.to_h if config_hash.respond_to?(:to_h)
|
|
450
|
+
config_hash.is_a?(Hash) ? (config_hash[key] || config_hash[key.to_s]) : nil
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Provider-level setting: extensions.llm.<provider>.<key>
|
|
351
454
|
def runtime_provider_setting(key)
|
|
352
455
|
return nil unless defined?(Legion::Settings)
|
|
353
456
|
|
|
@@ -363,10 +466,47 @@ module Legion
|
|
|
363
466
|
nil
|
|
364
467
|
end
|
|
365
468
|
|
|
469
|
+
# Global LLM setting: extensions.llm.<key> (lowest specificity)
|
|
470
|
+
def global_llm_setting(key)
|
|
471
|
+
return nil unless defined?(Legion::Settings)
|
|
472
|
+
|
|
473
|
+
llm_conf = Legion::Settings.dig(:extensions, :llm)
|
|
474
|
+
llm_conf.is_a?(Hash) ? llm_conf[key] : nil
|
|
475
|
+
rescue StandardError
|
|
476
|
+
nil
|
|
477
|
+
end
|
|
478
|
+
|
|
366
479
|
def model_allowed?(model_name)
|
|
367
|
-
name = model_name.to_s.downcase
|
|
368
480
|
wl = model_whitelist
|
|
369
481
|
bl = model_blacklist
|
|
482
|
+
allowed = self.class.policy_allows?(model_name, whitelist: wl, blacklist: bl)
|
|
483
|
+
|
|
484
|
+
unless allowed
|
|
485
|
+
reason_parts = []
|
|
486
|
+
reason_parts << 'whitelist' if wl.any?
|
|
487
|
+
reason_parts << 'blacklist' if bl.any?
|
|
488
|
+
reason_str = reason_parts.empty? ? 'policy' : reason_parts.join(',')
|
|
489
|
+
policy_src = if wl.any?
|
|
490
|
+
"wl=[#{wl.first(5).join(',')}#{',...' if wl.size > 5}]"
|
|
491
|
+
else
|
|
492
|
+
'no-whitelist'
|
|
493
|
+
end
|
|
494
|
+
log.debug("[#{self.class.slug}] action=model_rejected name=#{model_name} reason=#{reason_str} #{policy_src}")
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
allowed
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Single source of truth for model-policy matching, usable both at runtime
|
|
501
|
+
# (instance #model_allowed?) and at instance-config build time (provider
|
|
502
|
+
# extensions choosing a default_model that does not violate the policy).
|
|
503
|
+
# Substring, case-insensitive: a whitelist permits models containing any
|
|
504
|
+
# pattern; a blacklist denies models containing any pattern; whitelist is
|
|
505
|
+
# applied before blacklist. Empty list = no restriction from that side.
|
|
506
|
+
def self.policy_allows?(model_name, whitelist: [], blacklist: [])
|
|
507
|
+
name = model_name.to_s.downcase
|
|
508
|
+
wl = Array(whitelist).map { |p| p.to_s.downcase }
|
|
509
|
+
bl = Array(blacklist).map { |p| p.to_s.downcase }
|
|
370
510
|
|
|
371
511
|
return false if wl.any? && wl.none? { |p| name.include?(p) }
|
|
372
512
|
return false if bl.any? && bl.any? { |p| name.include?(p) }
|
|
@@ -374,6 +514,65 @@ module Legion
|
|
|
374
514
|
true
|
|
375
515
|
end
|
|
376
516
|
|
|
517
|
+
# Effective whitelist/blacklist for an instance config at build time
|
|
518
|
+
# (before provider instance exists). Same specificity cascade:
|
|
519
|
+
# 1. Per-instance (config hash — extensions.llm.<provider>.instances.<id>.model_whitelist)
|
|
520
|
+
# 2. Provider-level (extensions.llm.<provider>.model_whitelist)
|
|
521
|
+
# 3. Global (extensions.llm.model_whitelist)
|
|
522
|
+
def self.model_policy(config, provider_family)
|
|
523
|
+
cfg = config.is_a?(Hash) ? config : {}
|
|
524
|
+
provider_conf = CredentialSources.setting(:extensions, :llm, provider_family)
|
|
525
|
+
provider_conf = {} unless provider_conf.is_a?(Hash)
|
|
526
|
+
global_conf = (::Legion::Settings.dig(:extensions, :llm) if defined?(::Legion::Settings))
|
|
527
|
+
global_conf = {} unless global_conf.is_a?(Hash)
|
|
528
|
+
|
|
529
|
+
{
|
|
530
|
+
whitelist: resolve_policy_value(cfg, provider_conf, global_conf, :model_whitelist),
|
|
531
|
+
blacklist: resolve_policy_value(cfg, provider_conf, global_conf, :model_blacklist)
|
|
532
|
+
}
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# Resolve a single policy value with instance > provider > global precedence.
|
|
536
|
+
def self.resolve_policy_value(cfg, provider_conf, global_conf, key)
|
|
537
|
+
# Instance-level
|
|
538
|
+
val = cfg[key] || cfg[key.to_s]
|
|
539
|
+
return val if val && !val.to_s.empty? && (val.is_a?(Array) ? val.any? : true)
|
|
540
|
+
|
|
541
|
+
# Provider-level
|
|
542
|
+
val = provider_conf[key] || provider_conf[key.to_s]
|
|
543
|
+
return val if val && !val.to_s.empty? && (val.is_a?(Array) ? val.any? : true)
|
|
544
|
+
|
|
545
|
+
# Global
|
|
546
|
+
global_conf[key] || global_conf[key.to_s]
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Choose a default_model that never violates the model policy: prefer an
|
|
550
|
+
# explicitly-configured default when permitted; else a provider fallback when
|
|
551
|
+
# permitted; else nil, so routing resolves an allowed discovered model rather
|
|
552
|
+
# than forcing a policy-forbidden default. Keeps a whitelist/blacklist
|
|
553
|
+
# authoritative over any hardcoded provider default.
|
|
554
|
+
def self.policy_safe_default_model(configured:, fallback:, whitelist: [], blacklist: [])
|
|
555
|
+
return configured if configured && !configured.to_s.empty? &&
|
|
556
|
+
policy_allows?(configured, whitelist:, blacklist:)
|
|
557
|
+
return fallback if fallback && !fallback.to_s.empty? &&
|
|
558
|
+
policy_allows?(fallback, whitelist:, blacklist:)
|
|
559
|
+
|
|
560
|
+
nil
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Compliance guard: refuse to dispatch any request for a model excluded by
|
|
564
|
+
# the configured model_whitelist / model_blacklist. Invoked at every
|
|
565
|
+
# dispatch entry point (the last line before the model API call) so a
|
|
566
|
+
# denied model can never reach a provider API, regardless of caller. Fail
|
|
567
|
+
# closed — raises rather than silently routing elsewhere.
|
|
568
|
+
def enforce_model_allowed!(model_name)
|
|
569
|
+
return if model_allowed?(model_name)
|
|
570
|
+
|
|
571
|
+
log.warn("[#{slug}] action=model_denied model=#{model_name} instance=#{provider_instance_id} " \
|
|
572
|
+
'reason=model_whitelist_or_blacklist')
|
|
573
|
+
raise ModelNotAllowedError.new(model: model_name, provider: slug)
|
|
574
|
+
end
|
|
575
|
+
|
|
377
576
|
# ── Offering defaults ─────────────────────────────────────────────
|
|
378
577
|
|
|
379
578
|
def offering_transport
|
|
@@ -424,7 +623,7 @@ module Legion
|
|
|
424
623
|
Socket.tcp(uri.host, uri.port, connect_timeout: 1).close
|
|
425
624
|
true
|
|
426
625
|
rescue StandardError => e
|
|
427
|
-
handle_exception(e, level: :
|
|
626
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.url_reachable', url:)
|
|
428
627
|
false
|
|
429
628
|
end
|
|
430
629
|
|
|
@@ -447,7 +646,7 @@ module Legion
|
|
|
447
646
|
|
|
448
647
|
cache_local_instance? ? local_cache_get(key) : cache_get(key)
|
|
449
648
|
rescue StandardError => e
|
|
450
|
-
handle_exception(e, level: :
|
|
649
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.model_cache_get', key:)
|
|
451
650
|
nil
|
|
452
651
|
end
|
|
453
652
|
|
|
@@ -539,6 +738,33 @@ module Legion
|
|
|
539
738
|
|
|
540
739
|
private
|
|
541
740
|
|
|
741
|
+
def provider_capability_config
|
|
742
|
+
return {} unless defined?(Legion::Extensions::Llm::CredentialSources)
|
|
743
|
+
|
|
744
|
+
raw = Legion::Extensions::Llm::CredentialSources.setting(:extensions, :llm, slug.to_sym)
|
|
745
|
+
return {} unless raw.respond_to?(:to_h)
|
|
746
|
+
|
|
747
|
+
raw.to_h.except(:instances, 'instances')
|
|
748
|
+
rescue StandardError => e
|
|
749
|
+
handle_exception(e, level: :warn, handled: true, operation: "#{slug}.provider_capability_config")
|
|
750
|
+
{}
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def instance_capability_config
|
|
754
|
+
extract_capability_config(config)
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def model_capability_config(model_id)
|
|
758
|
+
provider_models = provider_capability_models
|
|
759
|
+
instance_models = extract_models_config(config)
|
|
760
|
+
provider_override = provider_models[model_id.to_s] || provider_models[model_id.to_sym] || {}
|
|
761
|
+
instance_override = instance_models[model_id.to_s] || instance_models[model_id.to_sym] || {}
|
|
762
|
+
provider_override.to_h.merge(instance_override.to_h)
|
|
763
|
+
rescue StandardError => e
|
|
764
|
+
handle_exception(e, level: :warn, handled: true, operation: "#{slug}.model_capability_config")
|
|
765
|
+
{}
|
|
766
|
+
end
|
|
767
|
+
|
|
542
768
|
def global_prompt_caching_enabled?
|
|
543
769
|
return false unless defined?(Legion::Settings)
|
|
544
770
|
|
|
@@ -577,6 +803,34 @@ module Legion
|
|
|
577
803
|
raise UnsupportedAttachmentError, "#{name} does not support image references in paint"
|
|
578
804
|
end
|
|
579
805
|
|
|
806
|
+
def extract_capability_config(source)
|
|
807
|
+
return {} unless source
|
|
808
|
+
|
|
809
|
+
CAPABILITY_CONFIG_KEYS.each_with_object({}) do |key, result|
|
|
810
|
+
next unless source.respond_to?(key)
|
|
811
|
+
|
|
812
|
+
value = source.public_send(key)
|
|
813
|
+
result[key] = value unless value.nil?
|
|
814
|
+
rescue StandardError
|
|
815
|
+
next
|
|
816
|
+
end
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
def extract_models_config(source)
|
|
820
|
+
return {} unless source.respond_to?(:models)
|
|
821
|
+
|
|
822
|
+
models = source.models
|
|
823
|
+
models.respond_to?(:to_h) ? models.to_h : {}
|
|
824
|
+
rescue StandardError
|
|
825
|
+
{}
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def provider_capability_models
|
|
829
|
+
config = provider_capability_config
|
|
830
|
+
models = config[:models] || config['models']
|
|
831
|
+
models.respond_to?(:to_h) ? models.to_h : {}
|
|
832
|
+
end
|
|
833
|
+
|
|
580
834
|
def offering_from_model(model, health: {})
|
|
581
835
|
capability_sources = Array(model.capabilities).to_h do |cap|
|
|
582
836
|
[cap.to_sym, { value: true, source: :model_metadata }]
|
|
@@ -679,7 +933,11 @@ module Legion
|
|
|
679
933
|
def health_status(readiness_data, raw_health)
|
|
680
934
|
return 'healthy' if readiness_data[:ready] == true || readiness_data['ready'] == true
|
|
681
935
|
|
|
682
|
-
status =
|
|
936
|
+
status = if raw_health.is_a?(Hash)
|
|
937
|
+
raw_health[:status] || raw_health['status'] || raw_health[:state] || raw_health['state']
|
|
938
|
+
else
|
|
939
|
+
raw_health
|
|
940
|
+
end
|
|
683
941
|
return 'healthy' if %w[ok ready healthy running].include?(status.to_s.downcase)
|
|
684
942
|
|
|
685
943
|
'unhealthy'
|
|
@@ -63,12 +63,13 @@ module Legion
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def readiness_health(readiness)
|
|
66
|
+
source = readiness[:health]
|
|
66
67
|
health = {
|
|
67
68
|
ready: readiness[:ready] == true,
|
|
68
69
|
status: readiness[:ready] ? :available : :unavailable,
|
|
69
|
-
checked:
|
|
70
|
+
checked: !source.is_a?(Hash) || source[:checked] != false
|
|
70
71
|
}
|
|
71
|
-
add_readiness_error(health,
|
|
72
|
+
add_readiness_error(health, source)
|
|
72
73
|
end
|
|
73
74
|
|
|
74
75
|
def add_readiness_error(health, source)
|
|
@@ -126,7 +127,7 @@ module Legion
|
|
|
126
127
|
value = configured_node.to_s.strip
|
|
127
128
|
value.empty? ? provider_family : value.to_sym
|
|
128
129
|
rescue StandardError => e
|
|
129
|
-
handle_exception(e, level: :
|
|
130
|
+
handle_exception(e, level: :warn, handled: true,
|
|
130
131
|
operation: "#{provider_family}.registry.provider_instance")
|
|
131
132
|
provider_family
|
|
132
133
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Extensions
|
|
5
7
|
module Llm
|
|
@@ -9,6 +11,8 @@ module Legion
|
|
|
9
11
|
class RegistryPublisher
|
|
10
12
|
include Legion::Logging::Helper
|
|
11
13
|
|
|
14
|
+
ASYNC_THREAD_POOL = Concurrent::FixedThreadPool.new(1, fallback_policy: :caller_runs)
|
|
15
|
+
|
|
12
16
|
attr_reader :provider_family
|
|
13
17
|
|
|
14
18
|
def initialize(provider_family:, builder: nil)
|
|
@@ -21,12 +25,12 @@ module Legion
|
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def publish_readiness_async(readiness)
|
|
24
|
-
log.
|
|
28
|
+
log.debug { "publishing readiness event to llm.registry for #{provider_family}" }
|
|
25
29
|
schedule { publish_event(@builder.readiness(readiness)) }
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
def publish_models_async(models, readiness:)
|
|
29
|
-
log.
|
|
33
|
+
log.debug { "publishing #{Array(models).size} model event(s) to llm.registry for #{provider_family}" }
|
|
30
34
|
schedule do
|
|
31
35
|
Array(models).each do |model|
|
|
32
36
|
publish_event(@builder.model_available(model, readiness:))
|
|
@@ -39,15 +43,14 @@ module Legion
|
|
|
39
43
|
def schedule(&)
|
|
40
44
|
return false unless publishing_available?
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
Thread.current.abort_on_exception = false
|
|
46
|
+
ASYNC_THREAD_POOL.post do
|
|
44
47
|
yield
|
|
45
48
|
rescue StandardError => e
|
|
46
|
-
handle_exception(e, level: :
|
|
49
|
+
handle_exception(e, level: :warn, handled: true,
|
|
47
50
|
operation: "#{provider_family}.registry.schedule_thread")
|
|
48
51
|
end
|
|
49
52
|
rescue StandardError => e
|
|
50
|
-
handle_exception(e, level: :
|
|
53
|
+
handle_exception(e, level: :warn, handled: true,
|
|
51
54
|
operation: "#{provider_family}.registry.schedule")
|
|
52
55
|
false
|
|
53
56
|
end
|
|
@@ -70,7 +73,7 @@ module Legion
|
|
|
70
73
|
|
|
71
74
|
::Legion::Transport::Connection.session_open?
|
|
72
75
|
rescue StandardError => e
|
|
73
|
-
handle_exception(e, level: :
|
|
76
|
+
handle_exception(e, level: :warn, handled: true,
|
|
74
77
|
operation: "#{provider_family}.registry.publishing_available?")
|
|
75
78
|
false
|
|
76
79
|
end
|
|
@@ -86,7 +89,7 @@ module Legion
|
|
|
86
89
|
require 'legion/extensions/llm/transport/messages/registry_event'
|
|
87
90
|
message_class_defined?
|
|
88
91
|
rescue LoadError => e
|
|
89
|
-
handle_exception(e, level: :
|
|
92
|
+
handle_exception(e, level: :warn, handled: true,
|
|
90
93
|
operation: "#{provider_family}.registry.transport_load")
|
|
91
94
|
false
|
|
92
95
|
end
|
|
@@ -159,21 +159,14 @@ module Legion
|
|
|
159
159
|
end
|
|
160
160
|
|
|
161
161
|
def normalize_capabilities(value)
|
|
162
|
-
|
|
163
|
-
symbol = item.to_s.downcase.strip.to_sym
|
|
164
|
-
next if symbol.to_s.empty?
|
|
165
|
-
|
|
166
|
-
normalized << symbol
|
|
167
|
-
alias_symbol = CAPABILITY_ALIASES[symbol]
|
|
168
|
-
normalized << alias_symbol if alias_symbol
|
|
169
|
-
end.uniq
|
|
162
|
+
Legion::Extensions::Llm::Capabilities.normalize(value)
|
|
170
163
|
end
|
|
171
164
|
|
|
172
165
|
def normalize_capability_sources(value)
|
|
173
166
|
normalize_hash(value).to_h do |capability, source_data|
|
|
174
167
|
normalized_source = normalize_hash(source_data)
|
|
175
168
|
[
|
|
176
|
-
|
|
169
|
+
Legion::Extensions::Llm::Capabilities.canonical(capability),
|
|
177
170
|
{ value: normalized_source[:value], source: normalized_source[:source]&.to_sym }.compact
|
|
178
171
|
]
|
|
179
172
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
module Taxonomies
|
|
7
|
+
TIERS = %i[direct local fleet cloud frontier].freeze
|
|
8
|
+
TYPES = %i[inference embedding image audio].freeze
|
|
9
|
+
CIRCUIT_STATES = %i[closed half_open open].freeze
|
|
10
|
+
HEALTH_KEYS = %i[circuit_state denied available adjustment].freeze
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -36,6 +36,12 @@ module Legion
|
|
|
36
36
|
# unqualified constant lookups resolve via Ruby scope. #
|
|
37
37
|
# ------------------------------------------------------------------ #
|
|
38
38
|
|
|
39
|
+
# --- P1 SSOT: taxonomy enums, capability normalization, inventory writer mixin ---
|
|
40
|
+
require_relative 'llm/taxonomies'
|
|
41
|
+
require_relative 'llm/capabilities'
|
|
42
|
+
require_relative 'llm/inventory/scoped_refresher'
|
|
43
|
+
require_relative 'llm/inventory/capabilities'
|
|
44
|
+
|
|
39
45
|
# --- Capability resolution policy (no internal deps) ---
|
|
40
46
|
require_relative 'llm/capability_policy'
|
|
41
47
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/capabilities'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Llm::Capabilities do
|
|
7
|
+
describe '.normalize' do
|
|
8
|
+
it 'normalizes :function_calling to :tools' do
|
|
9
|
+
expect(described_class.normalize([:function_calling])).to include(:tools)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'normalizes :tool_use to :tools' do
|
|
13
|
+
expect(described_class.normalize([:tool_use])).to include(:tools)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'passes through unknown capabilities unchanged' do
|
|
17
|
+
expect(described_class.normalize([:vision])).to include(:vision)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'returns frozen array' do
|
|
21
|
+
expect(described_class.normalize([:tools])).to be_frozen
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'handles nil/empty input gracefully' do
|
|
25
|
+
expect(described_class.normalize(nil)).to eq([])
|
|
26
|
+
expect(described_class.normalize([])).to eq([])
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe '.include_all?' do
|
|
31
|
+
it 'returns true when required caps are a subset of available' do
|
|
32
|
+
expect(described_class.include_all?(%i[tools vision], [:tools])).to be true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'returns false when a required cap is missing' do
|
|
36
|
+
expect(described_class.include_all?([:vision], [:tools])).to be false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'matches :function_calling against :tools via alias (PR #152 I1)' do
|
|
40
|
+
expect(described_class.include_all?([:function_calling], [:tools])).to be true
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
describe '.merge' do
|
|
45
|
+
it 'merges and deduplicates multiple capability sets' do
|
|
46
|
+
result = described_class.merge([:tools], %i[vision tools])
|
|
47
|
+
expect(result.count(:tools)).to eq(1)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|