lex-llm 0.5.4 → 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 +24 -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/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 +216 -12
- 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 +20 -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
|
|
|
@@ -145,11 +189,14 @@ module Legion
|
|
|
145
189
|
|
|
146
190
|
provider_health = health(live:)
|
|
147
191
|
@cached_offerings = Array(list_models(live:, **filters)).filter_map do |model|
|
|
192
|
+
publish_discovered_model_to_registry(model, provider_health:, live:)
|
|
148
193
|
next unless model_matches_filters?(model, filters)
|
|
149
194
|
next unless model_allowed?(model.id)
|
|
150
195
|
|
|
196
|
+
log.debug("[#{slug}] instance=#{provider_instance_id} action=model_discovered model=#{model.id} family=#{model.family}")
|
|
151
197
|
offering_from_model(model, health: provider_health)
|
|
152
198
|
end
|
|
199
|
+
log.info("[#{slug}] instance=#{provider_instance_id} action=discover_complete model_count=#{Array(@cached_offerings).size}")
|
|
153
200
|
@cached_offerings
|
|
154
201
|
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
155
202
|
log.warn("[#{slug}] instance=#{provider_instance_id} unreachable: #{e.message}")
|
|
@@ -158,17 +205,45 @@ module Legion
|
|
|
158
205
|
[]
|
|
159
206
|
end
|
|
160
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
|
+
|
|
161
235
|
def health(live: false)
|
|
162
236
|
readiness_data = readiness(live:)
|
|
163
237
|
raw_health = readiness_data[:health] || readiness_data['health'] || {}
|
|
164
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))
|
|
165
240
|
{
|
|
166
241
|
provider: slug.to_sym,
|
|
167
242
|
instance_id: provider_instance_id,
|
|
168
243
|
status:,
|
|
169
244
|
ready: readiness_data[:ready] == true || readiness_data['ready'] == true,
|
|
170
245
|
circuit_state: status == 'healthy' ? 'closed' : 'open',
|
|
171
|
-
latency_ms:
|
|
246
|
+
latency_ms: latency_ms,
|
|
172
247
|
raw: raw_health
|
|
173
248
|
}.compact
|
|
174
249
|
rescue StandardError => e
|
|
@@ -338,20 +413,44 @@ module Legion
|
|
|
338
413
|
|
|
339
414
|
# ── Model allow-list / deny-list filtering ────────────────────────
|
|
340
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.
|
|
341
421
|
def model_whitelist
|
|
342
422
|
wl = config.model_whitelist if config.respond_to?(:model_whitelist)
|
|
343
|
-
wl ||=
|
|
423
|
+
wl ||= instance_setting(:model_whitelist)
|
|
344
424
|
wl ||= runtime_provider_setting(:model_whitelist)
|
|
425
|
+
wl ||= global_llm_setting(:model_whitelist)
|
|
345
426
|
Array(wl).map { |p| p.to_s.downcase }
|
|
346
427
|
end
|
|
347
428
|
|
|
429
|
+
# Resolve model_blacklist with the same specificity cascade as model_whitelist.
|
|
348
430
|
def model_blacklist
|
|
349
431
|
bl = config.model_blacklist if config.respond_to?(:model_blacklist)
|
|
350
|
-
bl ||=
|
|
432
|
+
bl ||= instance_setting(:model_blacklist)
|
|
351
433
|
bl ||= runtime_provider_setting(:model_blacklist)
|
|
434
|
+
bl ||= global_llm_setting(:model_blacklist)
|
|
352
435
|
Array(bl).map { |p| p.to_s.downcase }
|
|
353
436
|
end
|
|
354
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>
|
|
355
454
|
def runtime_provider_setting(key)
|
|
356
455
|
return nil unless defined?(Legion::Settings)
|
|
357
456
|
|
|
@@ -367,8 +466,35 @@ module Legion
|
|
|
367
466
|
nil
|
|
368
467
|
end
|
|
369
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
|
+
|
|
370
479
|
def model_allowed?(model_name)
|
|
371
|
-
|
|
480
|
+
wl = model_whitelist
|
|
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
|
|
372
498
|
end
|
|
373
499
|
|
|
374
500
|
# Single source of truth for model-policy matching, usable both at runtime
|
|
@@ -388,19 +514,38 @@ module Legion
|
|
|
388
514
|
true
|
|
389
515
|
end
|
|
390
516
|
|
|
391
|
-
# Effective whitelist/blacklist for an instance config
|
|
392
|
-
#
|
|
393
|
-
#
|
|
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)
|
|
394
522
|
def self.model_policy(config, provider_family)
|
|
395
523
|
cfg = config.is_a?(Hash) ? config : {}
|
|
396
524
|
provider_conf = CredentialSources.setting(:extensions, :llm, provider_family)
|
|
397
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
|
+
|
|
398
529
|
{
|
|
399
|
-
whitelist: cfg
|
|
400
|
-
blacklist: cfg
|
|
530
|
+
whitelist: resolve_policy_value(cfg, provider_conf, global_conf, :model_whitelist),
|
|
531
|
+
blacklist: resolve_policy_value(cfg, provider_conf, global_conf, :model_blacklist)
|
|
401
532
|
}
|
|
402
533
|
end
|
|
403
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
|
+
|
|
404
549
|
# Choose a default_model that never violates the model policy: prefer an
|
|
405
550
|
# explicitly-configured default when permitted; else a provider fallback when
|
|
406
551
|
# permitted; else nil, so routing resolves an allowed discovered model rather
|
|
@@ -478,7 +623,7 @@ module Legion
|
|
|
478
623
|
Socket.tcp(uri.host, uri.port, connect_timeout: 1).close
|
|
479
624
|
true
|
|
480
625
|
rescue StandardError => e
|
|
481
|
-
handle_exception(e, level: :
|
|
626
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.url_reachable', url:)
|
|
482
627
|
false
|
|
483
628
|
end
|
|
484
629
|
|
|
@@ -501,7 +646,7 @@ module Legion
|
|
|
501
646
|
|
|
502
647
|
cache_local_instance? ? local_cache_get(key) : cache_get(key)
|
|
503
648
|
rescue StandardError => e
|
|
504
|
-
handle_exception(e, level: :
|
|
649
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.model_cache_get', key:)
|
|
505
650
|
nil
|
|
506
651
|
end
|
|
507
652
|
|
|
@@ -593,6 +738,33 @@ module Legion
|
|
|
593
738
|
|
|
594
739
|
private
|
|
595
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
|
+
|
|
596
768
|
def global_prompt_caching_enabled?
|
|
597
769
|
return false unless defined?(Legion::Settings)
|
|
598
770
|
|
|
@@ -631,6 +803,34 @@ module Legion
|
|
|
631
803
|
raise UnsupportedAttachmentError, "#{name} does not support image references in paint"
|
|
632
804
|
end
|
|
633
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
|
+
|
|
634
834
|
def offering_from_model(model, health: {})
|
|
635
835
|
capability_sources = Array(model.capabilities).to_h do |cap|
|
|
636
836
|
[cap.to_sym, { value: true, source: :model_metadata }]
|
|
@@ -733,7 +933,11 @@ module Legion
|
|
|
733
933
|
def health_status(readiness_data, raw_health)
|
|
734
934
|
return 'healthy' if readiness_data[:ready] == true || readiness_data['ready'] == true
|
|
735
935
|
|
|
736
|
-
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
|
|
737
941
|
return 'healthy' if %w[ok ready healthy running].include?(status.to_s.downcase)
|
|
738
942
|
|
|
739
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
|
|
@@ -14,7 +14,7 @@ RSpec.describe Legion::Extensions::Llm::CapabilityPolicy do
|
|
|
14
14
|
policy = described_class.resolve(**empty_sources)
|
|
15
15
|
|
|
16
16
|
expect(policy[:capabilities]).to eq([])
|
|
17
|
-
expect(policy[:sources][:
|
|
17
|
+
expect(policy[:sources][:embedding]).to eq(value: false, source: :default_false)
|
|
18
18
|
expect(policy[:sources][:thinking]).to eq(value: false, source: :default_false)
|
|
19
19
|
expect(policy[:sources][:streaming]).to eq(value: false, source: :default_false)
|
|
20
20
|
expect(policy[:sources][:tools]).to eq(value: false, source: :default_false)
|
|
@@ -42,7 +42,7 @@ RSpec.describe Legion::Extensions::Llm::CapabilityPolicy do
|
|
|
42
42
|
|
|
43
43
|
expect(policy[:capabilities]).to contain_exactly(:streaming, :tools, :thinking)
|
|
44
44
|
expect(policy[:sources][:thinking]).to eq(value: true, source: :instance_override)
|
|
45
|
-
expect(policy[:sources][:
|
|
45
|
+
expect(policy[:sources][:embedding]).to eq(value: false, source: :provider_override)
|
|
46
46
|
expect(policy[:sources][:tools]).to eq(value: true, source: :instance_override)
|
|
47
47
|
end
|
|
48
48
|
end
|
|
@@ -65,7 +65,7 @@ RSpec.describe Legion::Extensions::Llm::CapabilityPolicy do
|
|
|
65
65
|
|
|
66
66
|
expect(policy[:capabilities]).to contain_exactly(:streaming, :thinking)
|
|
67
67
|
expect(policy[:sources][:streaming]).to eq(value: true, source: :provider_override)
|
|
68
|
-
expect(policy[:sources][:
|
|
68
|
+
expect(policy[:sources][:embedding]).to eq(value: false, source: :provider_override)
|
|
69
69
|
expect(policy[:sources][:thinking]).to eq(value: true, source: :provider_override)
|
|
70
70
|
end
|
|
71
71
|
end
|
|
@@ -82,11 +82,11 @@ RSpec.describe Legion::Extensions::Llm::CapabilityPolicy do
|
|
|
82
82
|
model_config: { capabilities: { tools: true } }
|
|
83
83
|
)
|
|
84
84
|
|
|
85
|
-
expect(policy[:capabilities]).to include(:tools, :
|
|
85
|
+
expect(policy[:capabilities]).to include(:tools, :embedding, :streaming, :structured_output)
|
|
86
86
|
expect(policy[:capabilities]).not_to include(:vision)
|
|
87
87
|
expect(policy[:sources][:tools]).to eq(value: true, source: :model_override)
|
|
88
88
|
expect(policy[:sources][:vision]).to eq(value: false, source: :provider_override)
|
|
89
|
-
expect(policy[:sources][:
|
|
89
|
+
expect(policy[:sources][:embedding]).to eq(value: true, source: :probe)
|
|
90
90
|
expect(policy[:sources][:structured_output]).to eq(value: true, source: :provider_catalog)
|
|
91
91
|
expect(policy[:sources][:streaming]).to eq(value: true, source: :provider_envelope)
|
|
92
92
|
end
|
|
@@ -109,6 +109,29 @@ RSpec.describe Legion::Extensions::Llm::CapabilityPolicy do
|
|
|
109
109
|
expect(policy[:sources][:streaming]).to eq(value: true, source: :instance_override)
|
|
110
110
|
expect(policy[:sources][:tools]).to eq(value: false, source: :instance_override)
|
|
111
111
|
end
|
|
112
|
+
|
|
113
|
+
it 'canonicalizes reasoning, embedding, and image-generation aliases' do
|
|
114
|
+
policy = described_class.resolve(
|
|
115
|
+
real: {},
|
|
116
|
+
provider_catalog: {},
|
|
117
|
+
probe: {},
|
|
118
|
+
provider_envelope: {},
|
|
119
|
+
provider_config: {},
|
|
120
|
+
instance_config: {
|
|
121
|
+
enable_reasoning: true,
|
|
122
|
+
embeddings_flag: true,
|
|
123
|
+
image_generation_flag: true,
|
|
124
|
+
completion_flag: true
|
|
125
|
+
},
|
|
126
|
+
model_config: {}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
expect(policy[:capabilities]).to include(:thinking, :embedding, :image, :completion)
|
|
130
|
+
expect(policy[:sources][:thinking]).to eq(value: true, source: :instance_override)
|
|
131
|
+
expect(policy[:sources][:embedding]).to eq(value: true, source: :instance_override)
|
|
132
|
+
expect(policy[:sources][:image]).to eq(value: true, source: :instance_override)
|
|
133
|
+
expect(policy[:sources][:completion]).to eq(value: true, source: :instance_override)
|
|
134
|
+
end
|
|
112
135
|
end
|
|
113
136
|
|
|
114
137
|
context 'when capabilities hash wins over alias at same level' do
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::Inventory::Capabilities do
|
|
6
|
+
describe '.normalize' do
|
|
7
|
+
it 'returns symbols for raw capability names' do
|
|
8
|
+
expect(described_class.normalize(%i[tools streaming])).to contain_exactly(:tools, :streaming)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'collapses provider aliases into the canonical vocabulary' do
|
|
12
|
+
expect(described_class.normalize([:function_calling])).to include(:tools)
|
|
13
|
+
expect(described_class.normalize([:tool_use])).to include(:tools)
|
|
14
|
+
expect(described_class.normalize([:stream])).to include(:streaming)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'returns an empty array for nil/empty input' do
|
|
18
|
+
expect(described_class.normalize(nil)).to eq([])
|
|
19
|
+
expect(described_class.normalize([])).to eq([])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'is the same vocabulary as Legion::Extensions::Llm::Capabilities' do
|
|
23
|
+
expect(described_class::ALIASES).to equal(Legion::Extensions::Llm::Capabilities::ALIASES)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe '.merge' do
|
|
28
|
+
it 'unions multiple capability sets' do
|
|
29
|
+
result = described_class.merge([:tools], %i[streaming tools])
|
|
30
|
+
expect(result).to contain_exactly(:tools, :streaming)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
describe '.include_all?' do
|
|
35
|
+
it 'returns true when required is a subset of available' do
|
|
36
|
+
expect(described_class.include_all?(%i[tools streaming], [:tools])).to be(true)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'returns false when required is missing from available' do
|
|
40
|
+
expect(described_class.include_all?([:streaming], [:tools])).to be(false)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|