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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/lib/legion/extensions/llm/capabilities.rb +69 -0
  4. data/lib/legion/extensions/llm/capability_policy.rb +27 -18
  5. data/lib/legion/extensions/llm/credential_sources.rb +6 -6
  6. data/lib/legion/extensions/llm/error.rb +15 -0
  7. data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -1
  8. data/lib/legion/extensions/llm/fleet/settings.rb +2 -2
  9. data/lib/legion/extensions/llm/inventory/capabilities.rb +40 -0
  10. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +105 -0
  11. data/lib/legion/extensions/llm/model/info.rb +12 -1
  12. data/lib/legion/extensions/llm/provider.rb +265 -7
  13. data/lib/legion/extensions/llm/registry_event_builder.rb +4 -3
  14. data/lib/legion/extensions/llm/registry_publisher.rb +11 -8
  15. data/lib/legion/extensions/llm/routing/model_offering.rb +2 -9
  16. data/lib/legion/extensions/llm/taxonomies.rb +14 -0
  17. data/lib/legion/extensions/llm/version.rb +1 -1
  18. data/lib/legion/extensions/llm.rb +6 -0
  19. data/spec/legion/extensions/llm/capabilities_spec.rb +50 -0
  20. data/spec/legion/extensions/llm/capability_policy_spec.rb +28 -5
  21. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +43 -0
  22. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +209 -0
  23. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +1 -1
  24. data/spec/legion/extensions/llm/provider_spec.rb +76 -0
  25. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +3 -2
  26. data/spec/legion/extensions/llm/taxonomies_spec.rb +28 -0
  27. 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: raw_health[:latency_ms] || raw_health['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 ||= settings[:model_whitelist] if respond_to?(:settings) && settings.is_a?(Hash)
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 ||= settings[:model_blacklist] if respond_to?(:settings) && settings.is_a?(Hash)
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: :debug, handled: true, operation: 'llm.provider.url_reachable', url:)
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: :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:)
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 = 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
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: 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.3'
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