lex-llm 0.5.1 → 0.5.3

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: 97ce32819eeea5c69b1278c3bab36876fc420f7092d9428da4e801fe26073601
4
+ data.tar.gz: c91d281b0994aea741558c7ffacced69626da499c2bc7876a58c591b50f56137
5
5
  SHA512:
6
- metadata.gz: 6909cc6018428bde9983ab3cf001d31f87cd9a90c7c58571e6daa3041dbe25962f610ea37b79a1abf1094385feea37e6f2d466c2131ed41e939e7c4da8ac3980
7
- data.tar.gz: 5c2b52d64916c8b169a49dc3459ca1aac9c4af6f148865ae190368d364754dce3e35865bf3cfa525ce431cd0bfe0f940240c989b20f5a4be0f26c9cba4574471
6
+ metadata.gz: 931eb07b958e676e014804e044c8da11dfc42c866345b6a187c08294da0070e88b2fb30dc569c577a99ee2de95a5f4d55572c8b05ce6d79f327f3ec4a48a8350
7
+ data.tar.gz: 78450fa24b76f759218776b30b80a4d693b0395f58828d79593cb1ce4e640c8ca281f423dcc83144220707e837fd00c76bfc9c600c6382e209e18966d92fc5e6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.3 - 2026-06-16
4
+
5
+ ### Fixed
6
+ - **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.
7
+
8
+ ## 0.5.2 - 2026-06-15
9
+
10
+ ### Added
11
+ - **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.
12
+ - **Boolean aliases** — `enable_thinking`, `tools_flag`, `embedding_flag`, etc. map to canonical capability keys at any settings level.
13
+ - **ModelOffering#capability_sources** — Per-capability source metadata preserved through offering serialization.
14
+ - **Provider#offering_from_model** — Base class now generates `:model_metadata` source tags for capabilities from provider API responses.
15
+
3
16
  ## 0.5.1 - 2026-06-12
4
17
 
5
18
  ### 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 ||= []
@@ -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)
@@ -549,7 +551,11 @@ module Legion
549
551
  tier = offering_tier
550
552
  instance_key = cache_instance_key
551
553
  cred_fp = credential_cache_fragment
552
- key_parts = ['model_info', tier, slug, instance_key, cred_fp, model_name].compact
554
+ key_parts = [
555
+ 'model_info',
556
+ "schema#{MODEL_DETAIL_CACHE_SCHEMA_VERSION}",
557
+ tier, slug, instance_key, cred_fp, model_name
558
+ ].compact
553
559
  key_parts.join('.')
554
560
  end
555
561
 
@@ -572,6 +578,10 @@ module Legion
572
578
  end
573
579
 
574
580
  def offering_from_model(model, health: {})
581
+ capability_sources = Array(model.capabilities).to_h do |cap|
582
+ [cap.to_sym, { value: true, source: :model_metadata }]
583
+ end
584
+
575
585
  Routing::ModelOffering.new(
576
586
  provider_family: slug.to_sym,
577
587
  provider_instance: model.instance || provider_instance_id,
@@ -582,6 +592,7 @@ module Legion
582
592
  model_family: model.family,
583
593
  usage_type: offering_usage_type(model),
584
594
  capabilities: model.capabilities,
595
+ capability_sources: capability_sources,
585
596
  limits: offering_limits(model),
586
597
  health:,
587
598
  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.3'
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
@@ -610,4 +610,35 @@ RSpec.describe Legion::Extensions::Llm::Provider do
610
610
  end
611
611
  end
612
612
  end
613
+
614
+ describe '#model_detail_cache_key' do
615
+ let(:provider_class) do
616
+ Class.new(described_class) do
617
+ def api_base = 'https://test.invalid'
618
+
619
+ def self.slug = 'testprov'
620
+ end
621
+ end
622
+
623
+ let(:provider) do
624
+ provider_class.new({ request_timeout: 30, max_retries: 0, retry_interval: 0,
625
+ retry_backoff_factor: 0, retry_interval_randomness: 0 })
626
+ end
627
+
628
+ it 'includes the model-detail cache schema version in the key' do
629
+ key = provider.send(:model_detail_cache_key, 'test-model')
630
+ expect(key).to include("schema#{described_class::MODEL_DETAIL_CACHE_SCHEMA_VERSION}")
631
+ expect(key).to include('schema2')
632
+ end
633
+
634
+ it 'changes the key when the schema version constant changes' do
635
+ key_v2 = provider.send(:model_detail_cache_key, 'test-model')
636
+
637
+ stub_const("#{described_class}::MODEL_DETAIL_CACHE_SCHEMA_VERSION", 3)
638
+
639
+ key_v3 = provider.send(:model_detail_cache_key, 'test-model')
640
+ expect(key_v3).not_to eq(key_v2)
641
+ expect(key_v3).to include('schema3')
642
+ end
643
+ end
613
644
  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.3
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