lex-llm 0.5.3 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
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,10 @@
|
|
|
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
|
+
|
|
3
8
|
## 0.5.3 - 2026-06-16
|
|
4
9
|
|
|
5
10
|
### Fixed
|
|
@@ -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)
|
|
@@ -96,6 +96,7 @@ module Legion
|
|
|
96
96
|
|
|
97
97
|
def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil,
|
|
98
98
|
tool_prefs: nil, &)
|
|
99
|
+
enforce_model_allowed!(model)
|
|
99
100
|
normalized_temperature = maybe_normalize_temperature(temperature, model)
|
|
100
101
|
log_provider_request(
|
|
101
102
|
messages: messages,
|
|
@@ -184,6 +185,7 @@ module Legion
|
|
|
184
185
|
end
|
|
185
186
|
|
|
186
187
|
def embed(text:, model:, dimensions: nil, params: {}, headers: {})
|
|
188
|
+
enforce_model_allowed!(model)
|
|
187
189
|
payload = Utils.deep_merge(render_embedding_payload(text, model:, dimensions:), params)
|
|
188
190
|
response = @connection.post(embedding_url(model:), payload) do |req|
|
|
189
191
|
req.headers = headers.merge(req.headers) unless headers.empty?
|
|
@@ -192,12 +194,14 @@ module Legion
|
|
|
192
194
|
end
|
|
193
195
|
|
|
194
196
|
def moderate(input, model:)
|
|
197
|
+
enforce_model_allowed!(model)
|
|
195
198
|
payload = render_moderation_payload(input, model:)
|
|
196
199
|
response = @connection.post moderation_url, payload
|
|
197
200
|
parse_moderation_response(response, model:)
|
|
198
201
|
end
|
|
199
202
|
|
|
200
203
|
def paint(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Metrics/ParameterLists
|
|
204
|
+
enforce_model_allowed!(model)
|
|
201
205
|
validate_paint_inputs!(with:, mask:)
|
|
202
206
|
payload = render_image_payload(prompt, model:, size:, with:, mask:, params:)
|
|
203
207
|
response = @connection.post images_url(with:, mask:), payload
|
|
@@ -364,9 +368,19 @@ module Legion
|
|
|
364
368
|
end
|
|
365
369
|
|
|
366
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: [])
|
|
367
381
|
name = model_name.to_s.downcase
|
|
368
|
-
wl =
|
|
369
|
-
bl =
|
|
382
|
+
wl = Array(whitelist).map { |p| p.to_s.downcase }
|
|
383
|
+
bl = Array(blacklist).map { |p| p.to_s.downcase }
|
|
370
384
|
|
|
371
385
|
return false if wl.any? && wl.none? { |p| name.include?(p) }
|
|
372
386
|
return false if bl.any? && bl.any? { |p| name.include?(p) }
|
|
@@ -374,6 +388,46 @@ module Legion
|
|
|
374
388
|
true
|
|
375
389
|
end
|
|
376
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
|
+
|
|
377
431
|
# ── Offering defaults ─────────────────────────────────────────────
|
|
378
432
|
|
|
379
433
|
def offering_transport
|
|
@@ -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
|