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.
@@ -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: raw_health[:latency_ms] || raw_health['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 ||= settings[:model_whitelist] if respond_to?(:settings) && settings.is_a?(Hash)
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 ||= settings[:model_blacklist] if respond_to?(:settings) && settings.is_a?(Hash)
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
- self.class.policy_allows?(model_name, whitelist: model_whitelist, blacklist: model_blacklist)
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: per-instance config
392
- # first, then the provider-level setting (mirrors instance #model_whitelist
393
- # resolution order). Used by provider extensions when picking a default_model.
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[:model_whitelist] || provider_conf[:model_whitelist] || provider_conf['model_whitelist'],
400
- blacklist: cfg[:model_blacklist] || provider_conf[:model_blacklist] || provider_conf['model_blacklist']
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: :debug, handled: true, operation: 'llm.provider.url_reachable', url:)
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: :debug, handled: true, operation: 'llm.provider.model_cache_get', key:)
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 = raw_health[:status] || raw_health['status'] || raw_health[:state] || raw_health['state']
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: readiness.dig(:health, :checked) != false
70
+ checked: !source.is_a?(Hash) || source[:checked] != false
70
71
  }
71
- add_readiness_error(health, readiness[: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: :debug, handled: true,
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.info { "publishing readiness event to llm.registry for #{provider_family}" }
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.info { "publishing #{Array(models).size} model event(s) to llm.registry for #{provider_family}" }
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
- Thread.new do
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: :debug, handled: true,
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: :debug, handled: true,
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: :debug, handled: true,
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: :debug, handled: true,
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
- Array(value).compact.each_with_object([]) do |item, normalized|
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
- capability.to_s.downcase.tr('-', '_').to_sym,
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
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.5.4'
6
+ VERSION = '0.6.2'
7
7
  end
8
8
  end
9
9
  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][:embeddings]).to eq(value: false, source: :default_false)
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][:embeddings]).to eq(value: false, source: :provider_override)
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][:embeddings]).to eq(value: false, source: :provider_override)
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, :embeddings, :streaming, :structured_output)
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][:embeddings]).to eq(value: true, source: :probe)
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