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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/lex-llm.gemspec +1 -1
  4. data/lib/legion/extensions/llm/auto_registration.rb +3 -0
  5. data/lib/legion/extensions/llm/canonical/message.rb +5 -1
  6. data/lib/legion/extensions/llm/fleet/protocol.rb +12 -0
  7. data/lib/legion/extensions/llm/fleet/provider_responder.rb +40 -5
  8. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -0
  9. data/lib/legion/extensions/llm/fleet/worker_execution.rb +166 -2
  10. data/lib/legion/extensions/llm/inventory/callable_handle.rb +144 -0
  11. data/lib/legion/extensions/llm/inventory/errors.rb +34 -0
  12. data/lib/legion/extensions/llm/inventory/evidence.rb +134 -0
  13. data/lib/legion/extensions/llm/inventory/identity.rb +141 -0
  14. data/lib/legion/extensions/llm/inventory/immutable_value.rb +96 -0
  15. data/lib/legion/extensions/llm/inventory/probe_coordinator.rb +117 -0
  16. data/lib/legion/extensions/llm/inventory/probe_token.rb +146 -0
  17. data/lib/legion/extensions/llm/inventory/publisher.rb +116 -0
  18. data/lib/legion/extensions/llm/inventory/records.rb +599 -0
  19. data/lib/legion/extensions/llm/inventory/registry.rb +636 -0
  20. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +206 -0
  21. data/lib/legion/extensions/llm/inventory/snapshot.rb +91 -0
  22. data/lib/legion/extensions/llm/provider.rb +55 -29
  23. data/lib/legion/extensions/llm/routing/lane_key.rb +4 -0
  24. data/lib/legion/extensions/llm/routing/model_offering.rb +3 -0
  25. data/lib/legion/extensions/llm/routing/offering_registry.rb +2 -0
  26. data/lib/legion/extensions/llm/routing/provider_outcome.rb +54 -0
  27. data/lib/legion/extensions/llm/routing/records.rb +288 -0
  28. data/lib/legion/extensions/llm/settings_cascade.rb +120 -0
  29. data/lib/legion/extensions/llm/taxonomies.rb +90 -0
  30. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -1
  31. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +3 -1
  32. data/lib/legion/extensions/llm/version.rb +1 -1
  33. data/lib/legion/extensions/llm.rb +18 -0
  34. data/spec/legion/extensions/llm/canonical/message_spec.rb +24 -0
  35. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +1 -0
  36. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +1 -0
  37. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +1 -0
  38. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +1 -0
  39. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +1 -0
  40. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +1 -0
  41. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +84 -0
  42. data/spec/legion/extensions/llm/conformance/ssot_provider_conformance_spec.rb +13 -0
  43. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +196 -0
  44. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +179 -0
  45. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +26 -0
  46. data/spec/legion/extensions/llm/fleet_messages_spec.rb +14 -0
  47. data/spec/legion/extensions/llm/inventory/boot_spec.rb +80 -0
  48. data/spec/legion/extensions/llm/inventory/callable_handle_spec.rb +127 -0
  49. data/spec/legion/extensions/llm/inventory/evidence_spec.rb +138 -0
  50. data/spec/legion/extensions/llm/inventory/identity_spec.rb +280 -0
  51. data/spec/legion/extensions/llm/inventory/immutable_value_spec.rb +132 -0
  52. data/spec/legion/extensions/llm/inventory/probe_coordinator_spec.rb +107 -0
  53. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +107 -0
  54. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +144 -0
  55. data/spec/legion/extensions/llm/inventory/records_spec.rb +300 -0
  56. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +140 -0
  57. data/spec/legion/extensions/llm/inventory/registry_availability_spec.rb +152 -0
  58. data/spec/legion/extensions/llm/inventory/registry_callable_spec.rb +63 -0
  59. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +69 -0
  60. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +128 -0
  61. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +109 -0
  62. data/spec/legion/extensions/llm/provider_contract_spec.rb +20 -0
  63. data/spec/legion/extensions/llm/provider_spec.rb +83 -0
  64. data/spec/legion/extensions/llm/routing/provider_outcome_spec.rb +73 -0
  65. data/spec/legion/extensions/llm/routing/records_spec.rb +173 -0
  66. data/spec/legion/extensions/llm/settings_cascade_spec.rb +208 -0
  67. data/spec/legion/extensions/llm/taxonomies_spec.rb +51 -0
  68. data/spec/support/fake_llm_provider.rb +11 -0
  69. data/spec/support/fake_ssot_harness.rb +137 -0
  70. data/spec/support/ssot_registry_helpers.rb +75 -0
  71. 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