lex-llm 0.5.1 → 0.5.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2aef21677943bb1b621d6defad35f76e3ca8195ea971b172e7924c7b83774f62
4
- data.tar.gz: 8a46fdb9ead39ffb08335e019e4623844ad91de4af1cb30960802a39ecf75ab0
3
+ metadata.gz: a67345a318fe016e8b7c302f08cc335bf25f1e4605a2b16fd9d95a9c9d6ccd04
4
+ data.tar.gz: a0d2f7b5998b3a70754cb538515e581cb9a17ae7bc38b72de305159cc486edd5
5
5
  SHA512:
6
- metadata.gz: 6909cc6018428bde9983ab3cf001d31f87cd9a90c7c58571e6daa3041dbe25962f610ea37b79a1abf1094385feea37e6f2d466c2131ed41e939e7c4da8ac3980
7
- data.tar.gz: 5c2b52d64916c8b169a49dc3459ca1aac9c4af6f148865ae190368d364754dce3e35865bf3cfa525ce431cd0bfe0f940240c989b20f5a4be0f26c9cba4574471
6
+ metadata.gz: 0e1d43f8bfc296cc15e1389f153adc134baf7dfa051d5604617126bfbfc558ce02416caf24509521d43448fc05bc43b278c8cf4fb6a197465de10af36489ada5
7
+ data.tar.gz: 0163f3ab169203405a2081c79712343ad5e36ae76bafaa1c703ac20e30ec46324d584bb69da0ebed064a85f9eafcb054ed8f4cb4395789b516607993e3fee53e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.4 - 2026-06-17
4
+
5
+ ### Fixed
6
+ - **Model policy enforced at dispatch (compliance)** — `model_whitelist` / `model_blacklist` were only applied when *listing* models (`discover_offerings`); inference dispatch never checked them, so a denied model could still be invoked directly. Added `enforce_model_allowed!`, called at every dispatch entry point (`complete` — which backs `chat`/`stream_chat` — plus `embed`, `moderate`, `paint`), raising the new `ModelNotAllowedError` *before* any provider API call. Fail-closed, no exceptions. `ModelNotAllowedError` is a distinct, non-HTTP error so callers can treat it as a terminal policy outcome (non-retryable, non-escalatable) rather than a provider failure.
7
+
8
+ ## 0.5.3 - 2026-06-16
9
+
10
+ ### Fixed
11
+ - **Streaming error classification** — Partial non-2xx streaming responses now raise status-specific errors (`UnauthorizedError`, `ForbiddenError`, `RateLimitError`, `ServiceUnavailableError`, etc.) instead of always raising `ServerError`. This preserves auth failures for downstream escalation and circuit handling.
12
+
13
+ ## 0.5.2 - 2026-06-15
14
+
15
+ ### Added
16
+ - **CapabilityPolicy module** — Shared capability resolution with 7-layer precedence chain (model_override > instance_override > provider_override > model_metadata > provider_catalog > probe > provider_envelope > default_false). All optional capabilities default false.
17
+ - **Boolean aliases** — `enable_thinking`, `tools_flag`, `embedding_flag`, etc. map to canonical capability keys at any settings level.
18
+ - **ModelOffering#capability_sources** — Per-capability source metadata preserved through offering serialization.
19
+ - **Provider#offering_from_model** — Base class now generates `:model_metadata` source tags for capabilities from provider API responses.
20
+
3
21
  ## 0.5.1 - 2026-06-12
4
22
 
5
23
  ### Fixed
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ # Resolves capability truth from multiple sources with explicit precedence.
7
+ # Returns both a flat capability list and per-capability source metadata.
8
+ module CapabilityPolicy
9
+ OPTIONAL_CAPABILITIES = %i[
10
+ streaming tools vision embeddings thinking structured_output image audio_transcription audio_speech
11
+ ].freeze
12
+
13
+ BOOLEAN_ALIASES = {
14
+ enable_streaming: :streaming,
15
+ enable_tools: :tools,
16
+ enable_thinking: :thinking,
17
+ enable_vision: :vision,
18
+ enable_embeddings: :embeddings,
19
+ enable_images: :image,
20
+ streaming_flag: :streaming,
21
+ tool_flag: :tools,
22
+ tools_flag: :tools,
23
+ thinking_flag: :thinking,
24
+ vision_flag: :vision,
25
+ embedding_flag: :embeddings,
26
+ embeddings_flag: :embeddings,
27
+ image_flag: :image,
28
+ images_flag: :image
29
+ }.freeze
30
+
31
+ module_function
32
+
33
+ def resolve(real:, provider_catalog:, probe:, provider_envelope:, provider_config:, instance_config:, model_config:) # rubocop:disable Metrics/ParameterLists
34
+ sources = {}
35
+ OPTIONAL_CAPABILITIES.each do |capability|
36
+ sources[capability] = resolve_one(
37
+ capability,
38
+ real:, provider_catalog:, probe:, provider_envelope:,
39
+ provider_config:, instance_config:, model_config:
40
+ )
41
+ end
42
+
43
+ {
44
+ capabilities: sources.filter_map { |capability, data| capability if data[:value] == true },
45
+ sources: sources
46
+ }
47
+ end
48
+
49
+ def resolve_one(capability, real:, provider_catalog:, probe:, provider_envelope:, provider_config:, instance_config:, model_config:) # rubocop:disable Metrics/ParameterLists
50
+ model_overrides = normalized_overrides(model_config)
51
+ return { value: model_overrides[capability], source: :model_override } if model_overrides.key?(capability)
52
+
53
+ instance_overrides = normalized_overrides(instance_config)
54
+ return { value: instance_overrides[capability], source: :instance_override } if instance_overrides.key?(capability)
55
+
56
+ provider_overrides = normalized_overrides(provider_config)
57
+ return { value: provider_overrides[capability], source: :provider_override } if provider_overrides.key?(capability)
58
+
59
+ real_caps = normalized_booleans(real)
60
+ return { value: real_caps[capability], source: :model_metadata } if real_caps.key?(capability)
61
+
62
+ catalog_caps = normalized_booleans(provider_catalog)
63
+ return { value: catalog_caps[capability], source: :provider_catalog } if catalog_caps.key?(capability)
64
+
65
+ probe_caps = normalized_booleans(probe)
66
+ return { value: probe_caps[capability], source: :probe } if probe_caps.key?(capability)
67
+
68
+ provider_caps = normalized_booleans(provider_envelope)
69
+ return { value: provider_caps[capability], source: :provider_envelope } if provider_caps.key?(capability)
70
+
71
+ { value: false, source: :default_false }
72
+ end
73
+
74
+ def normalized_overrides(config)
75
+ config = normalize_hash(config)
76
+ caps_key = config.key?(:capabilities) ? :capabilities : 'capabilities'
77
+ overrides = normalized_booleans(config[caps_key])
78
+ BOOLEAN_ALIASES.each do |key, capability|
79
+ value = config[key]
80
+ value = config[key.to_s] if value.nil?
81
+ next unless [true, false].include?(value)
82
+ next if overrides.key?(capability)
83
+
84
+ overrides[capability] = value
85
+ end
86
+ overrides
87
+ end
88
+
89
+ def normalized_booleans(value)
90
+ normalize_hash(value).each_with_object({}) do |(key, raw), result|
91
+ capability = key.to_s.downcase.tr('-', '_').to_sym
92
+ next unless OPTIONAL_CAPABILITIES.include?(capability)
93
+ next unless [true, false].include?(raw)
94
+
95
+ result[capability] = raw
96
+ end
97
+ end
98
+
99
+ def normalize_hash(value)
100
+ return {} unless value.respond_to?(:to_h)
101
+
102
+ value.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -22,6 +22,10 @@ module Legion
22
22
  option_keys.dup
23
23
  end
24
24
 
25
+ def register_provider_options(keys)
26
+ Array(keys).each { |key| option(key.to_sym) }
27
+ end
28
+
25
29
  private
26
30
 
27
31
  def option_keys = @option_keys ||= []
@@ -27,6 +27,21 @@ module Legion
27
27
  class ModelNotFoundError < StandardError; end
28
28
  class UnsupportedAttachmentError < StandardError; end
29
29
 
30
+ # Raised when a request targets a model excluded by the configured
31
+ # model_whitelist / model_blacklist. This is a compliance guard enforced at
32
+ # the provider dispatch boundary (the last line before the model API call),
33
+ # so a denied model can never be reached regardless of caller. Non-retryable:
34
+ # retrying the same denied model must never succeed.
35
+ class ModelNotAllowedError < StandardError
36
+ attr_reader :model, :provider
37
+
38
+ def initialize(message = nil, model: nil, provider: nil)
39
+ @model = model
40
+ @provider = provider
41
+ super(message || "model #{model.inspect} is not permitted by the configured model policy for provider #{provider.inspect}")
42
+ end
43
+ end
44
+
30
45
  # Backward-compatible unsupported-capability error alias.
31
46
  class UnsupportedCapabilityError < Errors::UnsupportedCapability
32
47
  def initialize(message = nil, provider: nil, capability: nil, model: nil)
@@ -30,6 +30,8 @@ module Legion
30
30
  include Legion::Logging::Helper
31
31
  include Legion::Cache::Helper
32
32
 
33
+ MODEL_DETAIL_CACHE_SCHEMA_VERSION = 2
34
+
33
35
  attr_reader :config, :connection
34
36
 
35
37
  def initialize(config)
@@ -94,6 +96,7 @@ module Legion
94
96
 
95
97
  def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil,
96
98
  tool_prefs: nil, &)
99
+ enforce_model_allowed!(model)
97
100
  normalized_temperature = maybe_normalize_temperature(temperature, model)
98
101
  log_provider_request(
99
102
  messages: messages,
@@ -182,6 +185,7 @@ module Legion
182
185
  end
183
186
 
184
187
  def embed(text:, model:, dimensions: nil, params: {}, headers: {})
188
+ enforce_model_allowed!(model)
185
189
  payload = Utils.deep_merge(render_embedding_payload(text, model:, dimensions:), params)
186
190
  response = @connection.post(embedding_url(model:), payload) do |req|
187
191
  req.headers = headers.merge(req.headers) unless headers.empty?
@@ -190,12 +194,14 @@ module Legion
190
194
  end
191
195
 
192
196
  def moderate(input, model:)
197
+ enforce_model_allowed!(model)
193
198
  payload = render_moderation_payload(input, model:)
194
199
  response = @connection.post moderation_url, payload
195
200
  parse_moderation_response(response, model:)
196
201
  end
197
202
 
198
203
  def paint(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Metrics/ParameterLists
204
+ enforce_model_allowed!(model)
199
205
  validate_paint_inputs!(with:, mask:)
200
206
  payload = render_image_payload(prompt, model:, size:, with:, mask:, params:)
201
207
  response = @connection.post images_url(with:, mask:), payload
@@ -362,9 +368,19 @@ module Legion
362
368
  end
363
369
 
364
370
  def model_allowed?(model_name)
371
+ self.class.policy_allows?(model_name, whitelist: model_whitelist, blacklist: model_blacklist)
372
+ end
373
+
374
+ # Single source of truth for model-policy matching, usable both at runtime
375
+ # (instance #model_allowed?) and at instance-config build time (provider
376
+ # extensions choosing a default_model that does not violate the policy).
377
+ # Substring, case-insensitive: a whitelist permits models containing any
378
+ # pattern; a blacklist denies models containing any pattern; whitelist is
379
+ # applied before blacklist. Empty list = no restriction from that side.
380
+ def self.policy_allows?(model_name, whitelist: [], blacklist: [])
365
381
  name = model_name.to_s.downcase
366
- wl = model_whitelist
367
- bl = model_blacklist
382
+ wl = Array(whitelist).map { |p| p.to_s.downcase }
383
+ bl = Array(blacklist).map { |p| p.to_s.downcase }
368
384
 
369
385
  return false if wl.any? && wl.none? { |p| name.include?(p) }
370
386
  return false if bl.any? && bl.any? { |p| name.include?(p) }
@@ -372,6 +388,46 @@ module Legion
372
388
  true
373
389
  end
374
390
 
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.
394
+ def self.model_policy(config, provider_family)
395
+ cfg = config.is_a?(Hash) ? config : {}
396
+ provider_conf = CredentialSources.setting(:extensions, :llm, provider_family)
397
+ provider_conf = {} unless provider_conf.is_a?(Hash)
398
+ {
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']
401
+ }
402
+ end
403
+
404
+ # Choose a default_model that never violates the model policy: prefer an
405
+ # explicitly-configured default when permitted; else a provider fallback when
406
+ # permitted; else nil, so routing resolves an allowed discovered model rather
407
+ # than forcing a policy-forbidden default. Keeps a whitelist/blacklist
408
+ # authoritative over any hardcoded provider default.
409
+ def self.policy_safe_default_model(configured:, fallback:, whitelist: [], blacklist: [])
410
+ return configured if configured && !configured.to_s.empty? &&
411
+ policy_allows?(configured, whitelist:, blacklist:)
412
+ return fallback if fallback && !fallback.to_s.empty? &&
413
+ policy_allows?(fallback, whitelist:, blacklist:)
414
+
415
+ nil
416
+ end
417
+
418
+ # Compliance guard: refuse to dispatch any request for a model excluded by
419
+ # the configured model_whitelist / model_blacklist. Invoked at every
420
+ # dispatch entry point (the last line before the model API call) so a
421
+ # denied model can never reach a provider API, regardless of caller. Fail
422
+ # closed — raises rather than silently routing elsewhere.
423
+ def enforce_model_allowed!(model_name)
424
+ return if model_allowed?(model_name)
425
+
426
+ log.warn("[#{slug}] action=model_denied model=#{model_name} instance=#{provider_instance_id} " \
427
+ 'reason=model_whitelist_or_blacklist')
428
+ raise ModelNotAllowedError.new(model: model_name, provider: slug)
429
+ end
430
+
375
431
  # ── Offering defaults ─────────────────────────────────────────────
376
432
 
377
433
  def offering_transport
@@ -549,7 +605,11 @@ module Legion
549
605
  tier = offering_tier
550
606
  instance_key = cache_instance_key
551
607
  cred_fp = credential_cache_fragment
552
- key_parts = ['model_info', tier, slug, instance_key, cred_fp, model_name].compact
608
+ key_parts = [
609
+ 'model_info',
610
+ "schema#{MODEL_DETAIL_CACHE_SCHEMA_VERSION}",
611
+ tier, slug, instance_key, cred_fp, model_name
612
+ ].compact
553
613
  key_parts.join('.')
554
614
  end
555
615
 
@@ -572,6 +632,10 @@ module Legion
572
632
  end
573
633
 
574
634
  def offering_from_model(model, health: {})
635
+ capability_sources = Array(model.capabilities).to_h do |cap|
636
+ [cap.to_sym, { value: true, source: :model_metadata }]
637
+ end
638
+
575
639
  Routing::ModelOffering.new(
576
640
  provider_family: slug.to_sym,
577
641
  provider_instance: model.instance || provider_instance_id,
@@ -582,6 +646,7 @@ module Legion
582
646
  model_family: model.family,
583
647
  usage_type: offering_usage_type(model),
584
648
  capabilities: model.capabilities,
649
+ capability_sources: capability_sources,
585
650
  limits: offering_limits(model),
586
651
  health:,
587
652
  metadata: offering_metadata(model)
@@ -16,8 +16,8 @@ module Legion
16
16
  }.freeze
17
17
 
18
18
  attr_reader :offering_id, :provider_family, :model_family, :provider_instance, :instance_id, :transport,
19
- :tier, :model, :canonical_model_alias, :routing_metadata, :usage_type, :capabilities, :limits,
20
- :credentials, :health, :cost, :policy_tags, :metadata
19
+ :tier, :model, :canonical_model_alias, :routing_metadata, :usage_type, :capabilities,
20
+ :capability_sources, :limits, :credentials, :health, :cost, :policy_tags, :metadata
21
21
 
22
22
  def initialize(data)
23
23
  @metadata = normalize_hash(fetch_value(data, :metadata))
@@ -37,6 +37,7 @@ module Legion
37
37
  fetch_value(data, :kind) ||
38
38
  infer_usage_type(data)))
39
39
  @capabilities = normalize_capabilities(fetch_value(data, :capabilities))
40
+ @capability_sources = normalize_capability_sources(fetch_value(data, :capability_sources))
40
41
  @limits = normalize_hash(fetch_value(data, :limits))
41
42
  @credentials = fetch_value(data, :credentials)
42
43
  @health = normalize_hash(fetch_value(data, :health))
@@ -106,6 +107,7 @@ module Legion
106
107
  routing_metadata: routing_metadata,
107
108
  usage_type: usage_type,
108
109
  capabilities: capabilities,
110
+ capability_sources: capability_sources,
109
111
  limits: limits,
110
112
  credentials: credentials,
111
113
  health: health,
@@ -167,6 +169,16 @@ module Legion
167
169
  end.uniq
168
170
  end
169
171
 
172
+ def normalize_capability_sources(value)
173
+ normalize_hash(value).to_h do |capability, source_data|
174
+ normalized_source = normalize_hash(source_data)
175
+ [
176
+ capability.to_s.downcase.tr('-', '_').to_sym,
177
+ { value: normalized_source[:value], source: normalized_source[:source]&.to_sym }.compact
178
+ ]
179
+ end
180
+ end
181
+
170
182
  def normalize_hash(value)
171
183
  (value || {}).to_h.transform_keys(&:to_sym)
172
184
  end
@@ -142,7 +142,30 @@ module Legion
142
142
  end
143
143
  log.warn "[llm][streaming] action=handle_failed_response status=#{status} " \
144
144
  "partial_body=#{buffer.length}b msg=#{partial.inspect}"
145
- raise Legion::Extensions::Llm::ServerError, msg
145
+ raise_streaming_status_error(status, msg)
146
+ end
147
+
148
+ def raise_streaming_status_error(status, message)
149
+ response = Struct.new(:body, :status).new({ 'error' => { 'message' => message } }, status)
150
+ case status
151
+ when 400
152
+ raise Legion::Extensions::Llm::BadRequestError.new(response, message)
153
+ when 401
154
+ raise Legion::Extensions::Llm::UnauthorizedError.new(response, message)
155
+ when 403
156
+ raise Legion::Extensions::Llm::ForbiddenError.new(response, message)
157
+ when 429
158
+ raise Legion::Extensions::Llm::RateLimitError.new(response, message)
159
+ when 500
160
+ raise Legion::Extensions::Llm::ServerError.new(response, message)
161
+ when 502..504
162
+ raise Legion::Extensions::Llm::ServiceUnavailableError.new(response, message)
163
+ when 529
164
+ raise Legion::Extensions::Llm::OverloadedError.new(response, message)
165
+ else
166
+ provider = respond_to?(:parse_error) ? self : nil
167
+ Legion::Extensions::Llm::ErrorMiddleware.parse_error(provider: provider, response: response)
168
+ end
146
169
  end
147
170
 
148
171
  def handle_sse(chunk, parser, env, &)
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.5.1'
6
+ VERSION = '0.5.4'
7
7
  end
8
8
  end
9
9
  end
@@ -36,6 +36,9 @@ module Legion
36
36
  # unqualified constant lookups resolve via Ruby scope. #
37
37
  # ------------------------------------------------------------------ #
38
38
 
39
+ # --- Capability resolution policy (no internal deps) ---
40
+ require_relative 'llm/capability_policy'
41
+
39
42
  # --- Base value objects (no internal deps) ---
40
43
  require_relative 'llm/mime_type'
41
44
  require_relative 'llm/model/info'
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::CapabilityPolicy do
6
+ let(:empty_sources) do
7
+ { real: {}, provider_catalog: {}, probe: {}, provider_envelope: {}, provider_config: {}, instance_config: {},
8
+ model_config: {} }
9
+ end
10
+
11
+ describe '.resolve' do
12
+ context 'with no data at all' do
13
+ it 'defaults all optional capabilities to false' do
14
+ policy = described_class.resolve(**empty_sources)
15
+
16
+ expect(policy[:capabilities]).to eq([])
17
+ expect(policy[:sources][:embeddings]).to eq(value: false, source: :default_false)
18
+ expect(policy[:sources][:thinking]).to eq(value: false, source: :default_false)
19
+ expect(policy[:sources][:streaming]).to eq(value: false, source: :default_false)
20
+ expect(policy[:sources][:tools]).to eq(value: false, source: :default_false)
21
+ expect(policy[:sources][:vision]).to eq(value: false, source: :default_false)
22
+ end
23
+ end
24
+
25
+ context 'with instance override' do
26
+ it 'resolves capabilities from instance config' do
27
+ policy = described_class.resolve(
28
+ real: {},
29
+ provider_catalog: {},
30
+ probe: {},
31
+ provider_envelope: {},
32
+ provider_config: {
33
+ capabilities: { embeddings: false },
34
+ tools_flag: false
35
+ },
36
+ instance_config: {
37
+ capabilities: { streaming: true, tools: true },
38
+ enable_thinking: true
39
+ },
40
+ model_config: {}
41
+ )
42
+
43
+ expect(policy[:capabilities]).to contain_exactly(:streaming, :tools, :thinking)
44
+ expect(policy[:sources][:thinking]).to eq(value: true, source: :instance_override)
45
+ expect(policy[:sources][:embeddings]).to eq(value: false, source: :provider_override)
46
+ expect(policy[:sources][:tools]).to eq(value: true, source: :instance_override)
47
+ end
48
+ end
49
+
50
+ context 'with provider-level override' do
51
+ it 'resolves capabilities from provider config' do
52
+ policy = described_class.resolve(
53
+ real: {},
54
+ provider_catalog: {},
55
+ probe: {},
56
+ provider_envelope: {},
57
+ provider_config: {
58
+ capabilities: { streaming: true },
59
+ embedding_flag: false,
60
+ thinking_flag: true
61
+ },
62
+ instance_config: {},
63
+ model_config: {}
64
+ )
65
+
66
+ expect(policy[:capabilities]).to contain_exactly(:streaming, :thinking)
67
+ expect(policy[:sources][:streaming]).to eq(value: true, source: :provider_override)
68
+ expect(policy[:sources][:embeddings]).to eq(value: false, source: :provider_override)
69
+ expect(policy[:sources][:thinking]).to eq(value: true, source: :provider_override)
70
+ end
71
+ end
72
+
73
+ context 'with full precedence chain' do
74
+ it 'resolves each capability from the highest-priority source' do
75
+ policy = described_class.resolve(
76
+ real: { tools: false, vision: true },
77
+ provider_catalog: { structured_output: true },
78
+ probe: { embeddings: true },
79
+ provider_envelope: { streaming: true, tools: true },
80
+ provider_config: { capabilities: { tools: true, vision: false } },
81
+ instance_config: { capabilities: { tools: false } },
82
+ model_config: { capabilities: { tools: true } }
83
+ )
84
+
85
+ expect(policy[:capabilities]).to include(:tools, :embeddings, :streaming, :structured_output)
86
+ expect(policy[:capabilities]).not_to include(:vision)
87
+ expect(policy[:sources][:tools]).to eq(value: true, source: :model_override)
88
+ expect(policy[:sources][:vision]).to eq(value: false, source: :provider_override)
89
+ expect(policy[:sources][:embeddings]).to eq(value: true, source: :probe)
90
+ expect(policy[:sources][:structured_output]).to eq(value: true, source: :provider_catalog)
91
+ expect(policy[:sources][:streaming]).to eq(value: true, source: :provider_envelope)
92
+ end
93
+ end
94
+
95
+ context 'with boolean aliases' do
96
+ it 'resolves enable_* and *_flag aliases' do
97
+ policy = described_class.resolve(
98
+ real: {},
99
+ provider_catalog: {},
100
+ probe: {},
101
+ provider_envelope: {},
102
+ provider_config: {},
103
+ instance_config: { enable_thinking: true, streaming_flag: true, tools_flag: false },
104
+ model_config: {}
105
+ )
106
+
107
+ expect(policy[:capabilities]).to contain_exactly(:streaming, :thinking)
108
+ expect(policy[:sources][:thinking]).to eq(value: true, source: :instance_override)
109
+ expect(policy[:sources][:streaming]).to eq(value: true, source: :instance_override)
110
+ expect(policy[:sources][:tools]).to eq(value: false, source: :instance_override)
111
+ end
112
+ end
113
+
114
+ context 'when capabilities hash wins over alias at same level' do
115
+ it 'prefers capabilities nested key over boolean alias' do
116
+ policy = described_class.resolve(
117
+ real: {},
118
+ provider_catalog: {},
119
+ probe: {},
120
+ provider_envelope: {},
121
+ provider_config: {},
122
+ instance_config: { capabilities: { tools: true }, tools_flag: false },
123
+ model_config: {}
124
+ )
125
+
126
+ expect(policy[:capabilities]).to include(:tools)
127
+ expect(policy[:sources][:tools]).to eq(value: true, source: :instance_override)
128
+ end
129
+ end
130
+
131
+ context 'with model override' do
132
+ it 'model override beats instance and provider' do
133
+ policy = described_class.resolve(
134
+ real: {},
135
+ provider_catalog: {},
136
+ probe: {},
137
+ provider_envelope: {},
138
+ provider_config: { capabilities: { thinking: false } },
139
+ instance_config: { capabilities: { thinking: false } },
140
+ model_config: { thinking_flag: true }
141
+ )
142
+
143
+ expect(policy[:capabilities]).to include(:thinking)
144
+ expect(policy[:sources][:thinking]).to eq(value: true, source: :model_override)
145
+ end
146
+ end
147
+
148
+ context 'with provider envelope' do
149
+ it 'uses provider envelope when no overrides exist' do
150
+ policy = described_class.resolve(
151
+ real: {},
152
+ provider_catalog: {},
153
+ probe: {},
154
+ provider_envelope: { streaming: true },
155
+ provider_config: {},
156
+ instance_config: {},
157
+ model_config: {}
158
+ )
159
+
160
+ expect(policy[:capabilities]).to contain_exactly(:streaming)
161
+ expect(policy[:sources][:streaming]).to eq(value: true, source: :provider_envelope)
162
+ end
163
+ end
164
+ end
165
+
166
+ describe '.normalized_overrides' do
167
+ it 'handles string keys in capabilities hash' do
168
+ result = described_class.normalized_overrides({ 'capabilities' => { 'streaming' => true } })
169
+ expect(result[:streaming]).to be(true)
170
+ end
171
+
172
+ it 'handles symbol keys in capabilities hash' do
173
+ result = described_class.normalized_overrides({ capabilities: { streaming: true } })
174
+ expect(result[:streaming]).to be(true)
175
+ end
176
+
177
+ it 'ignores non-boolean values' do
178
+ result = described_class.normalized_overrides({ capabilities: { streaming: 'yes' } })
179
+ expect(result).not_to have_key(:streaming)
180
+ end
181
+ end
182
+
183
+ describe '.normalize_hash' do
184
+ it 'returns empty hash for nil' do
185
+ expect(described_class.normalize_hash(nil)).to eq({})
186
+ end
187
+
188
+ it 'symbolizes keys' do
189
+ expect(described_class.normalize_hash({ 'foo' => 1 })).to eq({ foo: 1 })
190
+ end
191
+ end
192
+ end
@@ -35,4 +35,44 @@ RSpec.describe Legion::Extensions::Llm::Configuration do
35
35
  expect(config.cache_control_prefix_tokens).to eq(4)
36
36
  end
37
37
  end
38
+
39
+ describe '.register_provider_options' do
40
+ after do
41
+ # Clean up test options to avoid polluting other specs
42
+ %i[test_api_key test_api_base].each do |key|
43
+ described_class.send(:option_keys).delete(key)
44
+ described_class.send(:defaults).delete(key)
45
+ described_class.send(:remove_method, key) if described_class.method_defined?(key)
46
+ described_class.send(:remove_method, :"#{key}=") if described_class.method_defined?(:"#{key}=")
47
+ end
48
+ end
49
+
50
+ it 'registers new options that become accessible on instances' do
51
+ described_class.register_provider_options(%i[test_api_key test_api_base])
52
+
53
+ config = described_class.new
54
+ expect(config).to respond_to(:test_api_key)
55
+ expect(config).to respond_to(:test_api_base)
56
+ end
57
+
58
+ it 'adds registered options to the options list' do
59
+ described_class.register_provider_options(%i[test_api_key test_api_base])
60
+
61
+ expect(described_class.options).to include(:test_api_key, :test_api_base)
62
+ end
63
+
64
+ it 'is idempotent — duplicate registrations do not add duplicates' do
65
+ described_class.register_provider_options(%i[test_api_key])
66
+ described_class.register_provider_options(%i[test_api_key])
67
+
68
+ count = described_class.options.count(:test_api_key)
69
+ expect(count).to eq(1)
70
+ end
71
+
72
+ it 'accepts string keys and normalizes them to symbols' do
73
+ described_class.register_provider_options(%w[test_api_key])
74
+
75
+ expect(described_class.options).to include(:test_api_key)
76
+ end
77
+ end
38
78
  end
@@ -357,6 +357,62 @@ RSpec.describe Legion::Extensions::Llm::Provider do
357
357
  end
358
358
  end
359
359
 
360
+ describe '#enforce_model_allowed! (dispatch compliance guard)' do
361
+ let(:provider_class) do
362
+ Class.new(described_class) do
363
+ attr_writer :settings
364
+
365
+ def api_base = 'https://test.invalid'
366
+ def settings = @settings || {}
367
+ def slug = :test
368
+ def provider_instance_id = :default
369
+ end
370
+ end
371
+
372
+ let(:provider) { provider_class.new(Legion::Extensions::Llm.config) }
373
+
374
+ context 'when a model is excluded by the whitelist' do
375
+ before { provider.settings = { model_whitelist: %w[haiku] } }
376
+
377
+ it 'raises ModelNotAllowedError carrying the model and provider' do
378
+ expect { provider.send(:enforce_model_allowed!, 'gpt-5') }
379
+ .to raise_error(Legion::Extensions::Llm::ModelNotAllowedError) do |error|
380
+ expect(error.model).to eq('gpt-5')
381
+ expect(error.provider).to eq(:test)
382
+ end
383
+ end
384
+
385
+ it 'permits a whitelisted model' do
386
+ expect { provider.send(:enforce_model_allowed!, 'claude-haiku-4-5-20251001') }.not_to raise_error
387
+ end
388
+
389
+ it 'fails closed in #complete before any provider call' do
390
+ expect { provider.complete([], tools: [], temperature: nil, model: 'gpt-5') }
391
+ .to raise_error(Legion::Extensions::Llm::ModelNotAllowedError)
392
+ end
393
+
394
+ it 'fails closed in #embed before any provider call' do
395
+ expect { provider.embed(text: 'hello', model: 'text-embedding-3-large') }
396
+ .to raise_error(Legion::Extensions::Llm::ModelNotAllowedError)
397
+ end
398
+ end
399
+
400
+ context 'when a model is excluded by the blacklist' do
401
+ before { provider.settings = { model_blacklist: %w[sonnet] } }
402
+
403
+ it 'fails closed in #complete for a blacklisted model' do
404
+ expect { provider.complete([], tools: [], temperature: nil, model: 'claude-sonnet-4-6') }
405
+ .to raise_error(Legion::Extensions::Llm::ModelNotAllowedError)
406
+ end
407
+ end
408
+
409
+ context 'with no policy configured' do
410
+ it 'does not raise for any model' do
411
+ expect { provider.send(:enforce_model_allowed!, 'anything-goes') }.not_to raise_error
412
+ end
413
+ end
414
+ end
415
+
360
416
  describe 'multi-host URL resolution' do
361
417
  let(:provider_class) do
362
418
  Class.new(described_class) do
@@ -610,4 +666,35 @@ RSpec.describe Legion::Extensions::Llm::Provider do
610
666
  end
611
667
  end
612
668
  end
669
+
670
+ describe '#model_detail_cache_key' do
671
+ let(:provider_class) do
672
+ Class.new(described_class) do
673
+ def api_base = 'https://test.invalid'
674
+
675
+ def self.slug = 'testprov'
676
+ end
677
+ end
678
+
679
+ let(:provider) do
680
+ provider_class.new({ request_timeout: 30, max_retries: 0, retry_interval: 0,
681
+ retry_backoff_factor: 0, retry_interval_randomness: 0 })
682
+ end
683
+
684
+ it 'includes the model-detail cache schema version in the key' do
685
+ key = provider.send(:model_detail_cache_key, 'test-model')
686
+ expect(key).to include("schema#{described_class::MODEL_DETAIL_CACHE_SCHEMA_VERSION}")
687
+ expect(key).to include('schema2')
688
+ end
689
+
690
+ it 'changes the key when the schema version constant changes' do
691
+ key_v2 = provider.send(:model_detail_cache_key, 'test-model')
692
+
693
+ stub_const("#{described_class}::MODEL_DETAIL_CACHE_SCHEMA_VERSION", 3)
694
+
695
+ key_v3 = provider.send(:model_detail_cache_key, 'test-model')
696
+ expect(key_v3).not_to eq(key_v2)
697
+ expect(key_v3).to include('schema3')
698
+ end
699
+ end
613
700
  end
@@ -219,4 +219,62 @@ RSpec.describe Legion::Extensions::Llm::Routing::ModelOffering do
219
219
  limits: { context_window: 32_768, max_output_tokens: 8192 }
220
220
  )
221
221
  end
222
+
223
+ describe 'capability_sources' do
224
+ it 'accepts and exposes capability source metadata' do
225
+ sourced = described_class.new(
226
+ provider_family: :vllm,
227
+ provider_instance: :apollo,
228
+ model: 'gemma-4-12b-it',
229
+ capabilities: %i[streaming tools],
230
+ capability_sources: {
231
+ streaming: { value: true, source: :provider_envelope },
232
+ tools: { value: true, source: :instance_override },
233
+ embeddings: { value: false, source: :default_false }
234
+ }
235
+ )
236
+
237
+ expect(sourced.capabilities).to include(:streaming, :tools)
238
+ expect(sourced.capability_sources[:tools]).to eq(value: true, source: :instance_override)
239
+ expect(sourced.capability_sources[:embeddings]).to eq(value: false, source: :default_false)
240
+ end
241
+
242
+ it 'includes capability_sources in to_h' do
243
+ sourced = described_class.new(
244
+ provider_family: :vllm,
245
+ provider_instance: :apollo,
246
+ model: 'gemma-4-12b-it',
247
+ capabilities: %i[streaming],
248
+ capability_sources: {
249
+ streaming: { value: true, source: :provider_envelope }
250
+ }
251
+ )
252
+
253
+ expect(sourced.to_h[:capability_sources]).to eq(
254
+ streaming: { value: true, source: :provider_envelope }
255
+ )
256
+ end
257
+
258
+ it 'normalizes string-keyed capability sources' do
259
+ sourced = described_class.new(
260
+ provider_family: :vllm,
261
+ model: 'test',
262
+ capability_sources: {
263
+ 'streaming' => { 'value' => true, 'source' => 'provider_envelope' }
264
+ }
265
+ )
266
+
267
+ expect(sourced.capability_sources[:streaming]).to eq(value: true, source: :provider_envelope)
268
+ end
269
+
270
+ it 'defaults to empty hash when not provided' do
271
+ plain = described_class.new(
272
+ provider_family: :ollama,
273
+ model: 'test',
274
+ capabilities: %i[streaming]
275
+ )
276
+
277
+ expect(plain.capability_sources).to eq({})
278
+ end
279
+ end
222
280
  end
@@ -98,6 +98,15 @@ RSpec.describe Legion::Extensions::Llm::Streaming do
98
98
  .to raise_error(Legion::Extensions::Llm::ServerError, /Provider error.*The model is currently overloaded/)
99
99
  end
100
100
 
101
+ it 'raises UnauthorizedError for partial 401 streaming responses' do
102
+ buffer = +''
103
+ auth_env = Struct.new(:status).new(401)
104
+ truncated_chunk = '{"error":{"message":"Unauthorized'
105
+
106
+ expect { test_obj.send(:handle_failed_response, truncated_chunk, buffer, auth_env) }
107
+ .to raise_error(Legion::Extensions::Llm::UnauthorizedError, /Unauthorized/)
108
+ end
109
+
101
110
  it 'raises ServerError with generic message when no partial message is extractable and env cannot buffer' do
102
111
  buffer = +''
103
112
  partial_chunk = '{"error":{'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.5.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -272,6 +272,7 @@ files:
272
272
  - lib/legion/extensions/llm/canonical/tool_definition.rb
273
273
  - lib/legion/extensions/llm/canonical/tool_schema.rb
274
274
  - lib/legion/extensions/llm/canonical/usage.rb
275
+ - lib/legion/extensions/llm/capability_policy.rb
275
276
  - lib/legion/extensions/llm/chat.rb
276
277
  - lib/legion/extensions/llm/chunk.rb
277
278
  - lib/legion/extensions/llm/configuration.rb
@@ -356,6 +357,7 @@ files:
356
357
  - spec/legion/extensions/llm/canonical/tool_definition_spec.rb
357
358
  - spec/legion/extensions/llm/canonical/tool_schema_spec.rb
358
359
  - spec/legion/extensions/llm/canonical/usage_spec.rb
360
+ - spec/legion/extensions/llm/capability_policy_spec.rb
359
361
  - spec/legion/extensions/llm/configuration_spec.rb
360
362
  - spec/legion/extensions/llm/conformance/client_translator_examples.rb
361
363
  - spec/legion/extensions/llm/conformance/conformance.rb