lex-llm 0.6.16 → 0.7.2
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 +29 -0
- data/lex-llm.gemspec +1 -1
- data/lib/legion/extensions/llm/auto_registration.rb +3 -0
- data/lib/legion/extensions/llm/canonical/message.rb +5 -1
- data/lib/legion/extensions/llm/fleet/protocol.rb +12 -0
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +40 -5
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -0
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +166 -2
- data/lib/legion/extensions/llm/inventory/callable_handle.rb +144 -0
- data/lib/legion/extensions/llm/inventory/errors.rb +34 -0
- data/lib/legion/extensions/llm/inventory/evidence.rb +134 -0
- data/lib/legion/extensions/llm/inventory/identity.rb +141 -0
- data/lib/legion/extensions/llm/inventory/immutable_value.rb +96 -0
- data/lib/legion/extensions/llm/inventory/probe_coordinator.rb +117 -0
- data/lib/legion/extensions/llm/inventory/probe_token.rb +146 -0
- data/lib/legion/extensions/llm/inventory/publisher.rb +116 -0
- data/lib/legion/extensions/llm/inventory/records.rb +599 -0
- data/lib/legion/extensions/llm/inventory/registry.rb +636 -0
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +206 -0
- data/lib/legion/extensions/llm/inventory/snapshot.rb +91 -0
- data/lib/legion/extensions/llm/provider.rb +55 -29
- data/lib/legion/extensions/llm/routing/lane_key.rb +4 -0
- data/lib/legion/extensions/llm/routing/model_offering.rb +3 -0
- data/lib/legion/extensions/llm/routing/offering_registry.rb +2 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +54 -0
- data/lib/legion/extensions/llm/routing/records.rb +288 -0
- data/lib/legion/extensions/llm/settings_cascade.rb +120 -0
- data/lib/legion/extensions/llm/taxonomies.rb +90 -0
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -1
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +3 -1
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +18 -0
- data/spec/legion/extensions/llm/canonical/message_spec.rb +24 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +84 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_conformance_spec.rb +13 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +196 -0
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +179 -0
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +26 -0
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +14 -0
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +80 -0
- data/spec/legion/extensions/llm/inventory/callable_handle_spec.rb +127 -0
- data/spec/legion/extensions/llm/inventory/evidence_spec.rb +138 -0
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +280 -0
- data/spec/legion/extensions/llm/inventory/immutable_value_spec.rb +132 -0
- data/spec/legion/extensions/llm/inventory/probe_coordinator_spec.rb +107 -0
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +107 -0
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +144 -0
- data/spec/legion/extensions/llm/inventory/records_spec.rb +300 -0
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +140 -0
- data/spec/legion/extensions/llm/inventory/registry_availability_spec.rb +152 -0
- data/spec/legion/extensions/llm/inventory/registry_callable_spec.rb +63 -0
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +69 -0
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +128 -0
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +109 -0
- data/spec/legion/extensions/llm/provider_contract_spec.rb +20 -0
- data/spec/legion/extensions/llm/provider_spec.rb +83 -0
- data/spec/legion/extensions/llm/routing/provider_outcome_spec.rb +73 -0
- data/spec/legion/extensions/llm/routing/records_spec.rb +173 -0
- data/spec/legion/extensions/llm/settings_cascade_spec.rb +208 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +51 -0
- data/spec/support/fake_llm_provider.rb +11 -0
- data/spec/support/fake_ssot_harness.rb +137 -0
- data/spec/support/ssot_registry_helpers.rb +75 -0
- metadata +40 -3
|
@@ -57,4 +57,24 @@ RSpec.describe Legion::Extensions::Llm::ProviderContract do
|
|
|
57
57
|
expect { provider.image('prompt', model: 'model') }.to raise_error(ArgumentError)
|
|
58
58
|
expect { provider.count_tokens([], model: 'model') }.to raise_error(ArgumentError)
|
|
59
59
|
end
|
|
60
|
+
|
|
61
|
+
it 'keeps REQUIRED_SIGNATURES as exactly the eight-entry reflection baseline' do
|
|
62
|
+
expect(described_class::REQUIRED_SIGNATURES).to eq(
|
|
63
|
+
chat: [%i[keyreq messages], %i[keyreq model]],
|
|
64
|
+
stream_chat: [%i[keyreq messages], %i[keyreq model]],
|
|
65
|
+
embed: [%i[keyreq text], %i[keyreq model]],
|
|
66
|
+
image: [%i[keyreq prompt], %i[keyreq model]],
|
|
67
|
+
list_models: [%i[key live], %i[keyrest filters]],
|
|
68
|
+
discover_offerings: [%i[key live], %i[key raise_on_unreachable], %i[keyrest filters]],
|
|
69
|
+
health: [%i[key live]],
|
|
70
|
+
count_tokens: [%i[keyreq messages], %i[keyreq model], %i[key params]]
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'does not smuggle optional operations or image.size into the reflection baseline' do
|
|
75
|
+
%i[size transcribe moderate disconnect speak translate normalize_dispatch_error].each do |operation|
|
|
76
|
+
expect(described_class::REQUIRED_SIGNATURES).not_to have_key(operation)
|
|
77
|
+
end
|
|
78
|
+
expect(described_class::REQUIRED_SIGNATURES[:image]).to eq([%i[keyreq prompt], %i[keyreq model]])
|
|
79
|
+
end
|
|
60
80
|
end
|
|
@@ -742,4 +742,87 @@ RSpec.describe Legion::Extensions::Llm::Provider do
|
|
|
742
742
|
expect { provider.disconnect }.not_to raise_error
|
|
743
743
|
end
|
|
744
744
|
end
|
|
745
|
+
|
|
746
|
+
describe 'fail-loud base audio operations' do
|
|
747
|
+
let(:provider) do
|
|
748
|
+
Class.new(described_class) do
|
|
749
|
+
def api_base = 'https://test.invalid'
|
|
750
|
+
def ensure_configured! = nil
|
|
751
|
+
end.new(request_timeout: 30, max_retries: 0, retry_interval: 0, retry_backoff_factor: 0, retry_interval_randomness: 0)
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
it 'raises NotImplementedError from translate without inferring a model' do
|
|
755
|
+
expect { provider.translate('audio', model: 'm', language: 'en') }
|
|
756
|
+
.to raise_error(NotImplementedError, /translate/)
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
it 'raises NotImplementedError from speak' do
|
|
760
|
+
expect { provider.speak('hello', model: 'm') }.to raise_error(NotImplementedError, /speak/)
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
it 'declares the protected keyword signatures without joining REQUIRED_SIGNATURES' do
|
|
764
|
+
translate_params = described_class.instance_method(:translate).parameters
|
|
765
|
+
expect(translate_params).to include(%i[req audio_file], %i[keyreq model], %i[keyreq language])
|
|
766
|
+
speak_params = described_class.instance_method(:speak).parameters
|
|
767
|
+
expect(speak_params).to include(%i[req text], %i[keyreq model], %i[key voice])
|
|
768
|
+
expect(Legion::Extensions::Llm::ProviderContract::REQUIRED_SIGNATURES).not_to have_key(:translate)
|
|
769
|
+
expect(Legion::Extensions::Llm::ProviderContract::REQUIRED_SIGNATURES).not_to have_key(:speak)
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
describe '#normalize_dispatch_error' do
|
|
774
|
+
llm = Legion::Extensions::Llm
|
|
775
|
+
|
|
776
|
+
let(:provider) do
|
|
777
|
+
Class.new(described_class) do
|
|
778
|
+
def api_base = 'https://test.invalid'
|
|
779
|
+
def ensure_configured! = nil
|
|
780
|
+
end.new(request_timeout: 30, max_retries: 0, retry_interval: 0, retry_backoff_factor: 0, retry_interval_randomness: 0)
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
it 'returns a Routing::ProviderOutcome and is not a REQUIRED_SIGNATURES entry' do
|
|
784
|
+
outcome = provider.normalize_dispatch_error(error: StandardError.new('x'))
|
|
785
|
+
expect(outcome).to be_a(Legion::Extensions::Llm::Routing::ProviderOutcome)
|
|
786
|
+
expect(Legion::Extensions::Llm::ProviderContract::REQUIRED_SIGNATURES).not_to have_key(:normalize_dispatch_error)
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
it 'maps the conservative typed error table' do
|
|
790
|
+
{
|
|
791
|
+
llm::OverloadedError.new('overloaded') => :overloaded,
|
|
792
|
+
llm::RateLimitError.new('rate') => :rate_limited,
|
|
793
|
+
llm::UnauthorizedError.new('auth') => :authentication,
|
|
794
|
+
llm::PaymentRequiredError.new('pay') => :billing,
|
|
795
|
+
llm::ForbiddenError.new('forbidden') => :authorization,
|
|
796
|
+
llm::ContextLengthExceededError.new('ctx') => :context_rejected,
|
|
797
|
+
llm::BadRequestError.new('bad') => :invalid_request,
|
|
798
|
+
llm::ModelNotFoundError.new('missing') => :model_missing,
|
|
799
|
+
llm::ModelNotAllowedError.new('denied') => :policy,
|
|
800
|
+
Faraday::TimeoutError.new('timeout') => :timeout,
|
|
801
|
+
Faraday::ConnectionFailed.new('conn') => :connection_failure
|
|
802
|
+
}.each do |error, expected_kind|
|
|
803
|
+
expect(provider.normalize_dispatch_error(error: error).kind).to eq(expected_kind)
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
it 'never maps raw 503/529, ServiceUnavailableError, ServerError, or unknown errors to instance_unavailable' do
|
|
808
|
+
[
|
|
809
|
+
llm::Error.new('raw 503 body'),
|
|
810
|
+
llm::Error.new('raw 529 body'),
|
|
811
|
+
llm::ServiceUnavailableError.new('503'),
|
|
812
|
+
llm::ServerError.new('500'),
|
|
813
|
+
StandardError.new('unexpected'),
|
|
814
|
+
RuntimeError.new('boom')
|
|
815
|
+
].each do |error|
|
|
816
|
+
outcome = provider.normalize_dispatch_error(error: error)
|
|
817
|
+
expect(outcome.kind).to eq(:provider_error)
|
|
818
|
+
expect(outcome.kind).not_to eq(:instance_unavailable)
|
|
819
|
+
end
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
it 'uses only the bounded exception class name as reason and never copies the response body' do
|
|
823
|
+
outcome = provider.normalize_dispatch_error(error: llm::OverloadedError.new(nil, 'super-secret-response-body'))
|
|
824
|
+
expect(outcome.reason).to eq('Legion::Extensions::Llm::OverloadedError')
|
|
825
|
+
expect(outcome.reason).not_to include('super-secret-response-body')
|
|
826
|
+
end
|
|
827
|
+
end
|
|
745
828
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/routing/provider_outcome'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Llm::Routing::ProviderOutcome do
|
|
7
|
+
inventory = Legion::Extensions::Llm::Inventory
|
|
8
|
+
routing = Legion::Extensions::Llm::Routing
|
|
9
|
+
errors = inventory::Errors
|
|
10
|
+
|
|
11
|
+
it 'builds a normalized outcome for each canonical kind' do
|
|
12
|
+
outcome = described_class.new(kind: :success, reason: 'ok')
|
|
13
|
+
expect(outcome.kind).to eq(:success)
|
|
14
|
+
expect(outcome.metadata).to be_frozen
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'rejects a kind outside PROVIDER_OUTCOMES' do
|
|
18
|
+
expect { described_class.new(kind: :made_up, reason: 'x') }.to raise_error(errors::ValidationError)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'has no HTTP status field: 503/529 cannot be an input' do
|
|
22
|
+
expect(described_class.members).not_to include(:http_status, :status_code)
|
|
23
|
+
expect(described_class.new(kind: :overloaded, reason: '503 overloaded').kind).to eq(:overloaded)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'never manufactures instance_unavailable from a raw status; only an explicit kind does' do
|
|
27
|
+
# An adapter that received HTTP 503 overload constructs :overloaded, not :instance_unavailable.
|
|
28
|
+
overloaded = described_class.new(kind: :overloaded, reason: 'HTTP 503 overloaded')
|
|
29
|
+
expect(overloaded.kind).not_to eq(:instance_unavailable)
|
|
30
|
+
# instance_unavailable exists only when an adapter explicitly constructs that kind.
|
|
31
|
+
explicit = described_class.new(kind: :instance_unavailable, reason: 'service unavailable')
|
|
32
|
+
expect(explicit.kind).to eq(:instance_unavailable)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'allows a quota_domain only for rate_limited' do
|
|
36
|
+
domain = routing::QuotaDomainKey.new(provider_family: 'vllm', opaque_id: 'q1')
|
|
37
|
+
expect(described_class.new(kind: :rate_limited, reason: 'quota', quota_domain: domain).quota_domain).to eq(domain)
|
|
38
|
+
expect { described_class.new(kind: :timeout, reason: 'x', quota_domain: domain) }
|
|
39
|
+
.to raise_error(errors::ValidationError)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'requires quota_domain to be a QuotaDomainKey' do
|
|
43
|
+
expect { described_class.new(kind: :rate_limited, reason: 'x', quota_domain: 'q1') }
|
|
44
|
+
.to raise_error(errors::ValidationError)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'accepts a finite nonnegative retry_after and rejects negatives/infinity' do
|
|
48
|
+
expect(described_class.new(kind: :rate_limited, reason: 'x', retry_after: 1.5).retry_after).to eq(1.5)
|
|
49
|
+
expect { described_class.new(kind: :rate_limited, reason: 'x', retry_after: -1) }
|
|
50
|
+
.to raise_error(errors::ValidationError)
|
|
51
|
+
expect { described_class.new(kind: :rate_limited, reason: 'x', retry_after: (1.0 / 0.0)) }
|
|
52
|
+
.to raise_error(errors::ValidationError)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'rejects secret metadata' do
|
|
56
|
+
expect { described_class.new(kind: :success, reason: 'ok', metadata: { api_key: 'x' }) }
|
|
57
|
+
.to raise_error(errors::ValidationError)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'coerces a non-UTF-8 (binary) provider error reason to valid UTF-8 instead of raising' do
|
|
61
|
+
# Regression guard (root cause A): RecordSupport.sanitized_reason previously raised
|
|
62
|
+
# ValidationError 'is not valid UTF-8' on an ASCII-8BIT/BINARY reason (a raw provider
|
|
63
|
+
# error body or Ruby kernel error message), masking the real dispatch error as an
|
|
64
|
+
# unclassifiable retriable 500. It now coerces to valid UTF-8 (undecodable bytes
|
|
65
|
+
# replaced), so a provider error can no longer mask itself.
|
|
66
|
+
raw = "provider 500 \xFF\x80 body".dup.force_encoding(Encoding::BINARY)
|
|
67
|
+
expect { described_class.new(kind: :provider_error, reason: raw) }.not_to raise_error
|
|
68
|
+
|
|
69
|
+
outcome = described_class.new(kind: :provider_error, reason: raw)
|
|
70
|
+
expect(outcome.kind).to eq(:provider_error)
|
|
71
|
+
expect(outcome.reason.valid_encoding?).to be(true)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/routing/records'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Llm::Routing::Records do
|
|
7
|
+
inventory = Legion::Extensions::Llm::Inventory
|
|
8
|
+
routing = Legion::Extensions::Llm::Routing
|
|
9
|
+
errors = inventory::Errors
|
|
10
|
+
identity = inventory::Identity
|
|
11
|
+
|
|
12
|
+
let(:callable_class) do
|
|
13
|
+
Class.new do
|
|
14
|
+
def disconnect; end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
let(:callable_handle) { inventory::CallableHandle.new(handle_id: 'call:v1:x', callable: callable_class.new) }
|
|
18
|
+
let(:instance_key) { identity::InstanceKey.new(provider_family: 'vllm', instance_id: 'h200') }
|
|
19
|
+
let(:offering_id) { identity.offering_id(instance_key: instance_key, provider_native_key: 'gemma4') }
|
|
20
|
+
let(:lane_id) do
|
|
21
|
+
identity.lane_id(instance_key: instance_key, operation: :chat, model: 'gemma4', offering_id: offering_id)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe routing::AttemptTargetKey do
|
|
25
|
+
it 'treats the same model on two instances as distinct keys' do
|
|
26
|
+
a = described_class.new(provider_family: 'vllm', instance_id: 'h200', model: 'gemma4')
|
|
27
|
+
b = described_class.new(provider_family: 'vllm', instance_id: 'helios1', model: 'gemma4')
|
|
28
|
+
expect(a).not_to eq(b)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'is value-equal for equal normalized fields and hashes equally' do
|
|
32
|
+
a = described_class.new(provider_family: 'vllm', instance_id: 'h200', model: 'gemma4')
|
|
33
|
+
b = described_class.new(provider_family: :vllm, instance_id: ' h200 ', model: 'gemma4')
|
|
34
|
+
expect(a).to eq(b)
|
|
35
|
+
expect(a.hash).to eq(b.hash)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'contains only provider_family, instance_id, and model' do
|
|
39
|
+
key = described_class.new(provider_family: 'vllm', instance_id: 'h200', model: 'gemma4')
|
|
40
|
+
expect(key.to_h.keys).to contain_exactly(:provider_family, :instance_id, :model)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
describe routing::QuotaDomainKey do
|
|
45
|
+
it 'never compares equal across provider families' do
|
|
46
|
+
a = described_class.new(provider_family: 'vllm', opaque_id: 'q1')
|
|
47
|
+
b = described_class.new(provider_family: 'openai', opaque_id: 'q1')
|
|
48
|
+
expect(a).not_to eq(b)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe routing::Exclusion do
|
|
53
|
+
it 'requires the target class to match the target_kind' do
|
|
54
|
+
expect do
|
|
55
|
+
described_class.new(target_kind: :instance, target: instance_key, reason: 'x', evidence: {}, lifetime: :request)
|
|
56
|
+
end.not_to raise_error
|
|
57
|
+
expect do
|
|
58
|
+
described_class.new(target_kind: :instance, target: 'nope', reason: 'x', evidence: {}, lifetime: :request)
|
|
59
|
+
end.to raise_error(errors::ValidationError)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'normalizes a provider target to a canonical Symbol' do
|
|
63
|
+
exclusion = described_class.new(target_kind: :provider, target: 'VLLM', reason: 'x', evidence: {}, lifetime: :request)
|
|
64
|
+
expect(exclusion.target).to eq(:vllm)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'freezes evidence and rejects an invalid lifetime' do
|
|
68
|
+
exclusion = described_class.new(target_kind: :model, target: 'gemma4', reason: 'x', evidence: { a: 1 }, lifetime: :attempt_group)
|
|
69
|
+
expect(exclusion.evidence).to be_frozen
|
|
70
|
+
expect do
|
|
71
|
+
described_class.new(target_kind: :model, target: 'gemma4', reason: 'x', evidence: {}, lifetime: :forever)
|
|
72
|
+
end.to raise_error(errors::ValidationError)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
describe routing::Selection do
|
|
77
|
+
def build_selection(**overrides)
|
|
78
|
+
Legion::Extensions::Llm::Routing::Selection.new(
|
|
79
|
+
inventory_generation: 7, lane_id: lane_id, instance_key: instance_key, offering_id: offering_id,
|
|
80
|
+
provider_family: :vllm, instance_id: 'h200', model: 'gemma4', operation: :chat,
|
|
81
|
+
callable_handle: callable_handle, publisher_token_id: 'ptok:v1:abc', capability_evidence: {},
|
|
82
|
+
context_evidence: Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :unknown, source: :absent),
|
|
83
|
+
weight_inputs: { tier: 2, provider: 3, instance: 5, model_or_offering: 7 },
|
|
84
|
+
base_weight: 210, preference_ppm: 1_000_000, effective_weight: 210_000_000,
|
|
85
|
+
rendezvous_score: 12_345, **overrides
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'builds a valid selection and derives the attempt target key' do
|
|
90
|
+
selection = build_selection
|
|
91
|
+
key = selection.attempt_target_key
|
|
92
|
+
expect(key).to eq(routing::AttemptTargetKey.new(provider_family: :vllm, instance_id: 'h200', model: 'gemma4'))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'requires base_weight to equal the product of weight_inputs' do
|
|
96
|
+
expect { build_selection(base_weight: 211) }.to raise_error(errors::ValidationError)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'requires effective_weight to equal base_weight * preference_ppm' do
|
|
100
|
+
expect { build_selection(effective_weight: 1) }.to raise_error(errors::ValidationError)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'bounds preference_ppm to 500_000..1_500_000' do
|
|
104
|
+
expect { build_selection(preference_ppm: 400_000, effective_weight: 210 * 400_000) }
|
|
105
|
+
.to raise_error(errors::ValidationError)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'rejects a lane_id that does not reproduce' do
|
|
109
|
+
expect { build_selection(lane_id: "lane:v1:#{'0' * 64}") }.to raise_error(errors::ValidationError)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'rejects a publisher_token_id without the ptok:v1: prefix' do
|
|
113
|
+
expect { build_selection(publisher_token_id: 'nope') }.to raise_error(errors::ValidationError)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'freezes weight_inputs' do
|
|
117
|
+
expect(build_selection.weight_inputs).to be_frozen
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
describe routing::Rejection do
|
|
122
|
+
def build_rejection(**overrides)
|
|
123
|
+
Legion::Extensions::Llm::Routing::Rejection.new(
|
|
124
|
+
kind: :too_early, reason: 'initializing', inventory_generation: 0, candidate_counts: { eligible: 0 }, **overrides
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'validates kind and freezes candidate_counts' do
|
|
129
|
+
rejection = build_rejection
|
|
130
|
+
expect(rejection.candidate_counts).to be_frozen
|
|
131
|
+
expect { build_rejection(kind: :made_up) }.to raise_error(errors::ValidationError)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'rejects candidate_counts with non-Symbol keys or negative values' do
|
|
135
|
+
expect { build_rejection(candidate_counts: { 'x' => 1 }) }.to raise_error(errors::ValidationError)
|
|
136
|
+
expect { build_rejection(candidate_counts: { eligible: -1 }) }.to raise_error(errors::ValidationError)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'allows only provider/instance/model/tier explicit pins' do
|
|
140
|
+
expect { build_rejection(explicit_pins: { provider: 'vllm', tier: 'local' }) }.not_to raise_error
|
|
141
|
+
expect { build_rejection(explicit_pins: { secret: 'x' }) }.to raise_error(errors::ValidationError)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'bounds http_status to 100..599' do
|
|
145
|
+
expect { build_rejection(http_status: 700) }.to raise_error(errors::ValidationError)
|
|
146
|
+
expect(build_rejection(http_status: 503).http_status).to eq(503)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
describe routing::BodyModelHintDecision do
|
|
151
|
+
it 'allows only honored to carry a model_constraint' do
|
|
152
|
+
expect do
|
|
153
|
+
described_class.new(
|
|
154
|
+
requested_model: 'gemma4', disposition: :honored, model_constraint: 'gemma4', settings_generation: 1
|
|
155
|
+
)
|
|
156
|
+
end.not_to raise_error
|
|
157
|
+
expect do
|
|
158
|
+
described_class.new(
|
|
159
|
+
requested_model: 'gemma4', disposition: :auto, model_constraint: 'gemma4', settings_generation: 1
|
|
160
|
+
)
|
|
161
|
+
end.to raise_error(errors::ValidationError)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it 'requires absent to carry a nil requested_model' do
|
|
165
|
+
expect do
|
|
166
|
+
described_class.new(requested_model: 'gemma4', disposition: :absent, settings_generation: 1)
|
|
167
|
+
end.to raise_error(errors::ValidationError)
|
|
168
|
+
expect do
|
|
169
|
+
described_class.new(requested_model: nil, disposition: :absent, settings_generation: 1)
|
|
170
|
+
end.not_to raise_error
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::SettingsCascade do
|
|
6
|
+
let(:llm_conf) do
|
|
7
|
+
{
|
|
8
|
+
vllm: {
|
|
9
|
+
weight: 5,
|
|
10
|
+
model_whitelist: %w[gpt],
|
|
11
|
+
models: {
|
|
12
|
+
'alpha' => { weight: 11, enable_tools: true }
|
|
13
|
+
},
|
|
14
|
+
instances: {
|
|
15
|
+
apollo: {
|
|
16
|
+
weight: 7,
|
|
17
|
+
enable_tools: false,
|
|
18
|
+
models: {
|
|
19
|
+
'alpha' => { weight: 13 },
|
|
20
|
+
'beta' => { enable_tools: true, preferred_min_context_tokens: 100 }
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
'string-keyed' => { weight: 9 }
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
ollama: {
|
|
27
|
+
instances: { local: {} }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe '.resolve_value (pure 3-level cascade, most-specific-first)' do
|
|
33
|
+
let(:provider_conf) { llm_conf[:vllm] }
|
|
34
|
+
let(:instance_conf) { llm_conf[:vllm][:instances][:apollo] }
|
|
35
|
+
|
|
36
|
+
it 'resolves the provider leg when no more specific scope carries the key' do
|
|
37
|
+
expect(described_class.resolve_value(provider_conf: provider_conf, instance_cfg: {}, key: :weight)).to eq(5)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'resolves the instance leg over the provider leg' do
|
|
41
|
+
expect(described_class.resolve_value(provider_conf: provider_conf, instance_cfg: instance_conf, key: :weight)).to eq(7)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'resolves the instance-scoped model leg over the provider-scoped model leg' do
|
|
45
|
+
result = described_class.resolve_value(
|
|
46
|
+
provider_conf: provider_conf, instance_cfg: instance_conf, key: :weight, model: 'alpha'
|
|
47
|
+
)
|
|
48
|
+
expect(result).to eq(13) # instance models.alpha (13) beats provider models.alpha (11) and instance weight (7)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'resolves the provider-scoped model leg when the instance model map has no entry' do
|
|
52
|
+
result = described_class.resolve_value(
|
|
53
|
+
provider_conf: provider_conf, instance_cfg: {}, key: :weight, model: 'alpha'
|
|
54
|
+
)
|
|
55
|
+
expect(result).to eq(11)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'resolves the model leg before the instance leg (most-specific-first)' do
|
|
59
|
+
result = described_class.resolve_value(
|
|
60
|
+
provider_conf: provider_conf, instance_cfg: instance_conf, key: :enable_tools, model: 'alpha'
|
|
61
|
+
)
|
|
62
|
+
# provider models.alpha.enable_tools (true) wins over instance enable_tools (false):
|
|
63
|
+
# the model leg is consulted before the instance leg
|
|
64
|
+
expect(result).to be(true)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'skips a model with no model-map entries and falls through to instance/provider' do
|
|
68
|
+
result = described_class.resolve_value(
|
|
69
|
+
provider_conf: provider_conf, instance_cfg: instance_conf, key: :weight, model: 'gamma'
|
|
70
|
+
)
|
|
71
|
+
expect(result).to eq(7)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'returns nil when no scope carries the key' do
|
|
75
|
+
expect(described_class.resolve_value(provider_conf: provider_conf, instance_cfg: instance_conf, key: :missing)).to be_nil
|
|
76
|
+
expect(described_class.resolve_value(provider_conf: {}, instance_cfg: {}, key: :weight)).to be_nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
context 'with empty values in scopes' do
|
|
80
|
+
it 'skips nil, blank String, and empty Array values, falling through to the next scope' do
|
|
81
|
+
conf = {
|
|
82
|
+
weight: '',
|
|
83
|
+
model_whitelist: [],
|
|
84
|
+
instances: { apollo: { weight: nil, model_whitelist: %w[claude], model_blacklist: [] } }
|
|
85
|
+
}
|
|
86
|
+
# instance nil + provider '' both skip -> nil
|
|
87
|
+
expect(described_class.resolve_value(provider_conf: conf, instance_cfg: conf[:instances][:apollo], key: :weight)).to be_nil
|
|
88
|
+
# instance '' / provider [] : a meaningful instance value resolves, an empty one falls to provider (empty -> nil)
|
|
89
|
+
expect(described_class.resolve_value(provider_conf: {}, instance_cfg: conf[:instances][:apollo], key: :model_whitelist)).to eq(%w[claude])
|
|
90
|
+
expect(described_class.resolve_value(provider_conf: conf, instance_cfg: {}, key: :model_whitelist)).to be_nil
|
|
91
|
+
# instance [] + provider absent -> nil
|
|
92
|
+
expect(described_class.resolve_value(provider_conf: conf, instance_cfg: conf[:instances][:apollo], key: :model_blacklist)).to be_nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'does NOT skip meaningful non-String values (false, 0)' do
|
|
96
|
+
conf = { enable_thinking: false, weight: 0 }
|
|
97
|
+
expect(described_class.resolve_value(provider_conf: conf, instance_cfg: {}, key: :enable_thinking)).to be(false)
|
|
98
|
+
expect(described_class.resolve_value(provider_conf: conf, instance_cfg: {}, key: :weight)).to eq(0)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'skips empty values in model scopes too' do
|
|
102
|
+
conf = {
|
|
103
|
+
models: { 'alpha' => { weight: '', enable_tools: true } },
|
|
104
|
+
instances: { apollo: { models: { 'alpha' => { enable_tools: false, weight: [] } } } }
|
|
105
|
+
}
|
|
106
|
+
# instance model weight [] skipped, provider model weight '' skipped, no instance/provider weight -> nil
|
|
107
|
+
expect(
|
|
108
|
+
described_class.resolve_value(provider_conf: conf, instance_cfg: conf[:instances][:apollo], key: :weight, model: 'alpha')
|
|
109
|
+
).to be_nil
|
|
110
|
+
# instance model enable_tools false is meaningful and wins
|
|
111
|
+
expect(
|
|
112
|
+
described_class.resolve_value(provider_conf: conf, instance_cfg: conf[:instances][:apollo], key: :enable_tools, model: 'alpha')
|
|
113
|
+
).to be(false)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
context 'with String and Symbol key/name variants' do
|
|
118
|
+
it 'accepts String or Symbol keys and instance/model names' do
|
|
119
|
+
expect(described_class.resolve_value(provider_conf: provider_conf, instance_cfg: instance_conf, key: 'weight')).to eq(7)
|
|
120
|
+
expect(described_class.resolve_value(provider_conf: provider_conf, instance_cfg: {}, key: :weight, model: :alpha)).to eq(11)
|
|
121
|
+
expect(
|
|
122
|
+
described_class.resolve_from(llm_conf: llm_conf, provider_family: 'vllm', instance: :apollo, key: :weight)
|
|
123
|
+
).to eq(7)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'accepts string-keyed settings hashes (YAML/JSON variants)' do
|
|
127
|
+
conf = { 'vllm' => { 'weight' => 3, 'instances' => { 'apollo' => { 'weight' => 4 } } } }
|
|
128
|
+
expect(described_class.resolve_from(llm_conf: conf, provider_family: :vllm, instance: 'apollo', key: :weight)).to eq(4)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'tolerates non-Hash scopes' do
|
|
132
|
+
expect(described_class.resolve_value(provider_conf: nil, instance_cfg: nil, key: :weight)).to be_nil
|
|
133
|
+
expect(described_class.resolve_from(llm_conf: nil, provider_family: :vllm, instance: 'apollo', key: :weight)).to be_nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
context 'with invalid argument types' do
|
|
138
|
+
it 'rejects non-text keys, instances, models, and provider families' do
|
|
139
|
+
expect { described_class.resolve_value(provider_conf: provider_conf, instance_cfg: {}, key: 5) }
|
|
140
|
+
.to raise_error(ArgumentError, /key must be a String or Symbol/)
|
|
141
|
+
expect { described_class.resolve_value(provider_conf: provider_conf, instance_cfg: {}, key: :weight, model: 5) }
|
|
142
|
+
.to raise_error(ArgumentError, /model must be a String or Symbol/)
|
|
143
|
+
expect { described_class.resolve_from(llm_conf: llm_conf, provider_family: 5, instance: 'a', key: :k) }
|
|
144
|
+
.to raise_error(ArgumentError, /provider_family must be a String or Symbol/)
|
|
145
|
+
expect { described_class.resolve_from(llm_conf: llm_conf, provider_family: :vllm, instance: 5, key: :k) }
|
|
146
|
+
.to raise_error(ArgumentError, /instance must be a String or Symbol/)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe '.merge_model_scopes (capability-feeder merge)' do
|
|
152
|
+
it 'merges the provider models.<model> entry with the instance models.<model> entry overriding it' do
|
|
153
|
+
merged = described_class.merge_model_scopes(
|
|
154
|
+
provider_conf: llm_conf[:vllm],
|
|
155
|
+
instance_cfg: llm_conf[:vllm][:instances][:apollo],
|
|
156
|
+
model: 'alpha'
|
|
157
|
+
)
|
|
158
|
+
expect(merged).to eq(weight: 13, enable_tools: true)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'returns {} when no scope carries a models map' do
|
|
162
|
+
expect(described_class.merge_model_scopes(provider_conf: {}, instance_cfg: {}, model: 'alpha')).to eq({})
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'ignores non-Hash model entries' do
|
|
166
|
+
conf = { models: { 'alpha' => 'not-a-hash' } }
|
|
167
|
+
expect(described_class.merge_model_scopes(provider_conf: conf, instance_cfg: {}, model: 'alpha')).to eq({})
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
describe '.resolve (live Legion::Settings path)' do
|
|
172
|
+
around do |example|
|
|
173
|
+
saved = Legion::Settings.loader.settings[:extensions]
|
|
174
|
+
example.run
|
|
175
|
+
ensure
|
|
176
|
+
if saved.nil?
|
|
177
|
+
Legion::Settings.loader.settings.delete(:extensions)
|
|
178
|
+
else
|
|
179
|
+
Legion::Settings.loader.settings[:extensions] = saved
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'reads the real nested extensions.llm.<provider> path, keyed by the config name' do
|
|
184
|
+
Legion::Settings.loader.settings[:extensions] = { llm: llm_conf }
|
|
185
|
+
|
|
186
|
+
expect(described_class.resolve(provider_family: :vllm, instance: 'apollo', key: :weight)).to eq(7)
|
|
187
|
+
expect(described_class.resolve(provider_family: :vllm, instance: 'apollo', key: :weight, model: 'alpha')).to eq(13)
|
|
188
|
+
expect(described_class.resolve(provider_family: :vllm, instance: 'nobody', key: :model_whitelist)).to eq(%w[gpt])
|
|
189
|
+
expect(described_class.resolve(provider_family: :vllm, instance: 'nobody', key: :missing)).to be_nil
|
|
190
|
+
expect(described_class.resolve(provider_family: :nope, instance: 'apollo', key: :weight)).to be_nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it 'returns nil when no extensions.llm subtree exists at all' do
|
|
194
|
+
Legion::Settings.loader.settings[:extensions] = {}
|
|
195
|
+
expect(described_class.resolve(provider_family: :vllm, instance: 'apollo', key: :weight)).to be_nil
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
describe '.resolve_from (frozen subtree, e.g. a router settings snapshot)' do
|
|
200
|
+
it 'resolves the same cascade against a pre-fetched extensions.llm hash' do
|
|
201
|
+
frozen_subtree = llm_conf
|
|
202
|
+
expect(described_class.resolve_from(llm_conf: frozen_subtree, provider_family: :vllm, instance: 'apollo', key: :weight)).to eq(7)
|
|
203
|
+
expect(
|
|
204
|
+
described_class.resolve_from(llm_conf: frozen_subtree, provider_family: :vllm, instance: 'string-keyed', key: :weight)
|
|
205
|
+
).to eq(9)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -25,4 +25,55 @@ RSpec.describe Legion::Extensions::Llm::Taxonomies do
|
|
|
25
25
|
expect(described_class::TYPES).to be_frozen
|
|
26
26
|
expect(described_class::CIRCUIT_STATES).to be_frozen
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
describe 'SSOT v3 enums' do
|
|
30
|
+
it 'OPERATIONS contains exactly the nine canonical operations' do
|
|
31
|
+
expect(described_class::OPERATIONS).to eq(
|
|
32
|
+
%i[chat stream_chat embed image transcribe translate speak moderate count_tokens]
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'freezes every SSOT enum constant' do
|
|
37
|
+
%i[
|
|
38
|
+
OPERATIONS CAPABILITY_EVIDENCE_STATES OPERATION_EVIDENCE_STATES VALUE_EVIDENCE_STATES
|
|
39
|
+
AVAILABILITY_STATES AVAILABILITY_SOURCES PROBE_OUTCOMES PUBLICATION_STATES CALLABLE_STATES
|
|
40
|
+
MUTATION_REASONS PROVIDER_OUTCOMES REJECTION_KINDS EXCLUSION_TARGET_KINDS EXCLUSION_LIFETIMES
|
|
41
|
+
BODY_MODEL_HINT_DISPOSITIONS EVIDENCE_SOURCES UNKNOWN_ONLY_EVIDENCE_SOURCES
|
|
42
|
+
].each { |const| expect(described_class.const_get(const)).to be_frozen }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'UNKNOWN_ONLY_EVIDENCE_SOURCES is a subset of EVIDENCE_SOURCES' do
|
|
46
|
+
expect(described_class::EVIDENCE_SOURCES).to include(*described_class::UNKNOWN_ONLY_EVIDENCE_SOURCES)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe '.normalize_operation' do
|
|
51
|
+
it 'returns a canonical operation unchanged' do
|
|
52
|
+
expect(described_class.normalize_operation(value: 'chat')).to eq(:chat)
|
|
53
|
+
expect(described_class.normalize_operation(value: :stream_chat)).to eq(:stream_chat)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'accepts only the documented aliases when allow_aliases is true' do
|
|
57
|
+
{
|
|
58
|
+
stream: :stream_chat, embedding: :embed, moderation: :moderate,
|
|
59
|
+
audio_translation: :translate, speech: :speak
|
|
60
|
+
}.each do |input, canonical|
|
|
61
|
+
expect(described_class.normalize_operation(value: input, allow_aliases: true)).to eq(canonical)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'raises on an alias when allow_aliases is false' do
|
|
66
|
+
expect { described_class.normalize_operation(value: :stream) }
|
|
67
|
+
.to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'raises on unknown, empty, and invalid UTF-8 input' do
|
|
71
|
+
expect { described_class.normalize_operation(value: :nope) }
|
|
72
|
+
.to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError)
|
|
73
|
+
expect { described_class.normalize_operation(value: ' ') }
|
|
74
|
+
.to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError)
|
|
75
|
+
expect { described_class.normalize_operation(value: "\xFF".b) }
|
|
76
|
+
.to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
28
79
|
end
|
|
@@ -96,6 +96,17 @@ module SpecSupport
|
|
|
96
96
|
def transcribe(_audio_file, model:, language:, **)
|
|
97
97
|
Legion::Extensions::Llm::Transcription.new(text: 'fake transcript', model: model, language: language)
|
|
98
98
|
end
|
|
99
|
+
|
|
100
|
+
# Records exactly-once disconnect for the callable-lifecycle contract; does
|
|
101
|
+
# not add disconnect to ProviderContract::REQUIRED_SIGNATURES.
|
|
102
|
+
def disconnect_count
|
|
103
|
+
@disconnect_count || 0
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def disconnect
|
|
107
|
+
@disconnect_count = disconnect_count + 1
|
|
108
|
+
super
|
|
109
|
+
end
|
|
99
110
|
end
|
|
100
111
|
|
|
101
112
|
class BackupFakeLLMProvider < FakeLLMProvider
|