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: 97ce32819eeea5c69b1278c3bab36876fc420f7092d9428da4e801fe26073601
4
- data.tar.gz: c91d281b0994aea741558c7ffacced69626da499c2bc7876a58c591b50f56137
3
+ metadata.gz: a67345a318fe016e8b7c302f08cc335bf25f1e4605a2b16fd9d95a9c9d6ccd04
4
+ data.tar.gz: a0d2f7b5998b3a70754cb538515e581cb9a17ae7bc38b72de305159cc486edd5
5
5
  SHA512:
6
- metadata.gz: 931eb07b958e676e014804e044c8da11dfc42c866345b6a187c08294da0070e88b2fb30dc569c577a99ee2de95a5f4d55572c8b05ce6d79f327f3ec4a48a8350
7
- data.tar.gz: 78450fa24b76f759218776b30b80a4d693b0395f58828d79593cb1ce4e640c8ca281f423dcc83144220707e837fd00c76bfc9c600c6382e209e18966d92fc5e6
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 = model_whitelist
369
- bl = model_blacklist
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
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.5.3'
6
+ VERSION = '0.5.4'
7
7
  end
8
8
  end
9
9
  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
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.3
4
+ version: 0.5.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO