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 +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/legion/extensions/llm/capability_policy.rb +107 -0
- data/lib/legion/extensions/llm/configuration.rb +4 -0
- data/lib/legion/extensions/llm/provider.rb +12 -1
- data/lib/legion/extensions/llm/routing/model_offering.rb +14 -2
- data/lib/legion/extensions/llm/streaming.rb +24 -1
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +3 -0
- data/spec/legion/extensions/llm/capability_policy_spec.rb +192 -0
- data/spec/legion/extensions/llm/configuration_spec.rb +40 -0
- data/spec/legion/extensions/llm/provider_spec.rb +31 -0
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +58 -0
- data/spec/legion/extensions/llm/streaming_spec.rb +9 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 97ce32819eeea5c69b1278c3bab36876fc420f7092d9428da4e801fe26073601
|
|
4
|
+
data.tar.gz: c91d281b0994aea741558c7ffacced69626da499c2bc7876a58c591b50f56137
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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 = [
|
|
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,
|
|
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
|
-
|
|
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, &)
|
|
@@ -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.
|
|
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
|