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 +4 -4
- data/CHANGELOG.md +18 -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/error.rb +15 -0
- data/lib/legion/extensions/llm/provider.rb +68 -3
- 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 +87 -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: a67345a318fe016e8b7c302f08cc335bf25f1e4605a2b16fd9d95a9c9d6ccd04
|
|
4
|
+
data.tar.gz: a0d2f7b5998b3a70754cb538515e581cb9a17ae7bc38b72de305159cc486edd5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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 =
|
|
367
|
-
bl =
|
|
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 = [
|
|
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,
|
|
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
|
|
@@ -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.
|
|
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
|