lex-llm 0.6.16 → 0.7.0
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 +14 -0
- data/lib/legion/extensions/llm/auto_registration.rb +3 -0
- 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 +119 -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 +110 -0
- data/lib/legion/extensions/llm/inventory/records.rb +592 -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 +43 -0
- 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/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 +15 -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 +125 -0
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +227 -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 +104 -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 +59 -0
- data/spec/legion/extensions/llm/routing/records_spec.rb +173 -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 +36 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/inventory/evidence'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Llm::Inventory::Evidence do
|
|
7
|
+
let(:errors) { Legion::Extensions::Llm::Inventory::Errors }
|
|
8
|
+
let(:operation_evidence) { Legion::Extensions::Llm::Inventory::OperationEvidence }
|
|
9
|
+
let(:capability_evidence) { Legion::Extensions::Llm::Inventory::CapabilityEvidence }
|
|
10
|
+
let(:value_evidence) { Legion::Extensions::Llm::Inventory::ValueEvidence }
|
|
11
|
+
|
|
12
|
+
describe 'OperationEvidence' do
|
|
13
|
+
it 'stores a canonical operation and exposes exact predicates' do
|
|
14
|
+
ev = operation_evidence.new(operation: :chat, status: :supported, source: :provider_implementation)
|
|
15
|
+
expect(ev.operation).to eq(:chat)
|
|
16
|
+
expect(ev).to be_supported
|
|
17
|
+
expect(ev).not_to be_unknown
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'keeps stream_chat as the operation and never canonicalizes to capability streaming' do
|
|
21
|
+
ev = operation_evidence.new(operation: :stream_chat, status: :supported, source: :provider_catalog)
|
|
22
|
+
expect(ev.operation).to eq(:stream_chat)
|
|
23
|
+
expect(ev.operation).not_to eq(:streaming)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'rejects an operation alias when allow_aliases is false (registry path)' do
|
|
27
|
+
expect { operation_evidence.new(operation: :stream, status: :supported, source: :provider_catalog) }
|
|
28
|
+
.to raise_error(errors::ValidationError)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'rejects an unknown operation' do
|
|
32
|
+
expect { operation_evidence.new(operation: :nonsense, status: :unknown, source: :absent) }
|
|
33
|
+
.to raise_error(errors::ValidationError)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'rejects a status outside supported/unsupported/unknown' do
|
|
37
|
+
expect { operation_evidence.new(operation: :chat, status: :maybe, source: :provider_implementation) }
|
|
38
|
+
.to raise_error(errors::ValidationError)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'rejects a source outside EVIDENCE_SOURCES' do
|
|
42
|
+
expect { operation_evidence.new(operation: :chat, status: :supported, source: :made_up) }
|
|
43
|
+
.to raise_error(errors::ValidationError)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'requires unknown status for an unknown-only source' do
|
|
47
|
+
expect { operation_evidence.new(operation: :chat, status: :supported, source: :default_false) }
|
|
48
|
+
.to raise_error(errors::ValidationError)
|
|
49
|
+
expect { operation_evidence.new(operation: :chat, status: :unknown, source: :default_false) }
|
|
50
|
+
.not_to raise_error
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'freezes copied metadata' do
|
|
54
|
+
ev = operation_evidence.new(
|
|
55
|
+
operation: :chat, status: :supported, source: :provider_implementation, metadata: { note: +'x' }
|
|
56
|
+
)
|
|
57
|
+
expect(ev.metadata).to be_frozen
|
|
58
|
+
expect(ev.metadata[:note]).to be_frozen
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'duplicates and freezes observed_at but rejects a non-Time' do
|
|
62
|
+
now = Time.now
|
|
63
|
+
ev = operation_evidence.new(operation: :chat, status: :supported, source: :probe, observed_at: now)
|
|
64
|
+
expect(ev.observed_at).to be_frozen
|
|
65
|
+
expect(ev.observed_at).not_to equal(now)
|
|
66
|
+
expect { operation_evidence.new(operation: :chat, status: :supported, source: :probe, observed_at: 5) }
|
|
67
|
+
.to raise_error(errors::ValidationError)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
describe 'CapabilityEvidence' do
|
|
72
|
+
it 'canonicalizes a capability alias' do
|
|
73
|
+
ev = capability_evidence.new(capability: :tool_use, status: :supported, source: :provider_implementation)
|
|
74
|
+
expect(ev.capability).to eq(:tools)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'canonicalizes the stream_chat capability alias to streaming (distinct from the operation)' do
|
|
78
|
+
ev = capability_evidence.new(capability: :stream_chat, status: :supported, source: :provider_catalog)
|
|
79
|
+
expect(ev.capability).to eq(:streaming)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'rejects a non-canonical capability' do
|
|
83
|
+
expect { capability_evidence.new(capability: :telepathy, status: :unknown, source: :absent) }
|
|
84
|
+
.to raise_error(errors::ValidationError)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'requires unknown status for guessed/default_false/failed_probe sources' do
|
|
88
|
+
%i[guessed default_false failed_probe inconclusive_probe].each do |source|
|
|
89
|
+
expect { capability_evidence.new(capability: :vision, status: :unsupported, source: source) }
|
|
90
|
+
.to raise_error(errors::ValidationError)
|
|
91
|
+
expect { capability_evidence.new(capability: :vision, status: :unknown, source: source) }
|
|
92
|
+
.not_to raise_error
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
describe 'ValueEvidence' do
|
|
98
|
+
it 'requires a non-nil value when known and copies/freezes it' do
|
|
99
|
+
ev = value_evidence.new(status: :known, value: 128_000, source: :model_metadata)
|
|
100
|
+
expect(ev).to be_known
|
|
101
|
+
expect(ev.value).to eq(128_000)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'freezes a known collection value' do
|
|
105
|
+
ev = value_evidence.new(status: :known, value: [512, 1024], source: :provider_catalog)
|
|
106
|
+
expect(ev.value).to be_frozen
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'rejects a known status with a nil value' do
|
|
110
|
+
expect { value_evidence.new(status: :known, value: nil, source: :model_metadata) }
|
|
111
|
+
.to raise_error(errors::ValidationError)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it 'requires nil value when unknown' do
|
|
115
|
+
expect { value_evidence.new(status: :unknown, value: 5, source: :absent) }
|
|
116
|
+
.to raise_error(errors::ValidationError)
|
|
117
|
+
expect(value_evidence.new(status: :unknown, source: :absent)).to be_unknown
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'enforces the unknown-only source rule' do
|
|
121
|
+
expect { value_evidence.new(status: :known, value: 10, source: :guessed) }
|
|
122
|
+
.to raise_error(errors::ValidationError)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/inventory/identity'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Llm::Inventory::Identity do
|
|
7
|
+
let(:errors) { Legion::Extensions::Llm::Inventory::Errors }
|
|
8
|
+
let(:instance_key_class) { Legion::Extensions::Llm::Inventory::Identity::InstanceKey }
|
|
9
|
+
|
|
10
|
+
fixture_path = File.expand_path('../conformance/fixtures/ssot_identity_vectors.json', __dir__)
|
|
11
|
+
fixture = Legion::JSON.load(File.read(fixture_path))
|
|
12
|
+
|
|
13
|
+
def hex(binary)
|
|
14
|
+
binary.b.unpack1('H*')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe 'binding identity vectors (recomputed field by field)' do
|
|
18
|
+
fixture[:vectors].each_with_index do |vector, index|
|
|
19
|
+
context "with vector #{index}: #{vector[:provider_family]}+#{vector[:instance_id]}" do
|
|
20
|
+
let(:instance_key) do
|
|
21
|
+
instance_key_class.new(
|
|
22
|
+
provider_family: vector[:provider_family],
|
|
23
|
+
instance_id: vector[:instance_id]
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'reproduces every framed field as lowercase hex bytes' do
|
|
28
|
+
expect(hex(described_class.length_frame(value: instance_key.provider_family)))
|
|
29
|
+
.to eq(vector[:framed_hex][:provider_family])
|
|
30
|
+
expect(hex(described_class.length_frame(value: instance_key.instance_id)))
|
|
31
|
+
.to eq(vector[:framed_hex][:instance_id])
|
|
32
|
+
expect(hex(described_class.length_frame(value: vector[:provider_native_key])))
|
|
33
|
+
.to eq(vector[:framed_hex][:provider_native_key])
|
|
34
|
+
expect(hex(described_class.length_frame(value: vector[:operation])))
|
|
35
|
+
.to eq(vector[:framed_hex][:operation])
|
|
36
|
+
expect(hex(described_class.length_frame(value: vector[:model])))
|
|
37
|
+
.to eq(vector[:framed_hex][:model])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'reproduces the offering hash input and offering_id' do
|
|
41
|
+
input = described_class.length_frame(value: instance_key.provider_family) +
|
|
42
|
+
described_class.length_frame(value: instance_key.instance_id) +
|
|
43
|
+
described_class.length_frame(value: vector[:provider_native_key])
|
|
44
|
+
expect(hex(input)).to eq(vector[:offering_hash_input_hex])
|
|
45
|
+
|
|
46
|
+
offering_id = described_class.offering_id(
|
|
47
|
+
instance_key: instance_key, provider_native_key: vector[:provider_native_key]
|
|
48
|
+
)
|
|
49
|
+
expect(offering_id).to eq(vector[:expected_offering_id])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'reproduces the lane hash input and lane_id' do
|
|
53
|
+
offering_id = described_class.offering_id(
|
|
54
|
+
instance_key: instance_key, provider_native_key: vector[:provider_native_key]
|
|
55
|
+
)
|
|
56
|
+
input = "lane-v1\x00".b +
|
|
57
|
+
described_class.length_frame(value: instance_key.provider_family) +
|
|
58
|
+
described_class.length_frame(value: instance_key.instance_id) +
|
|
59
|
+
described_class.length_frame(value: vector[:operation]) +
|
|
60
|
+
described_class.length_frame(value: vector[:model]) +
|
|
61
|
+
described_class.length_frame(value: offering_id)
|
|
62
|
+
expect(hex(input)).to eq(vector[:lane_hash_input_hex])
|
|
63
|
+
|
|
64
|
+
lane_id = described_class.lane_id(
|
|
65
|
+
instance_key: instance_key,
|
|
66
|
+
operation: vector[:operation].to_sym,
|
|
67
|
+
model: vector[:model],
|
|
68
|
+
offering_id: offering_id
|
|
69
|
+
)
|
|
70
|
+
expect(lane_id).to eq(vector[:expected_lane_id])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'validates the reproduced offering_id and lane_id' do
|
|
74
|
+
offering_id = vector[:expected_offering_id]
|
|
75
|
+
expect(
|
|
76
|
+
described_class.validate_offering_id!(
|
|
77
|
+
value: offering_id, instance_key: instance_key, provider_native_key: vector[:provider_native_key]
|
|
78
|
+
)
|
|
79
|
+
).to eq(offering_id)
|
|
80
|
+
expect(
|
|
81
|
+
described_class.validate_lane_id!(
|
|
82
|
+
value: vector[:expected_lane_id], instance_key: instance_key,
|
|
83
|
+
operation: vector[:operation].to_sym, model: vector[:model], offering_id: offering_id
|
|
84
|
+
)
|
|
85
|
+
).to eq(vector[:expected_lane_id])
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
describe 'independence and delimiter safety' do
|
|
92
|
+
it 'produces distinct offering/lane IDs for the same model on two instance IDs' do
|
|
93
|
+
expect(fixture[:vectors][0][:expected_offering_id]).not_to eq(fixture[:vectors][1][:expected_offering_id])
|
|
94
|
+
expect(fixture[:vectors][0][:expected_lane_id]).not_to eq(fixture[:vectors][1][:expected_lane_id])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'is tier-independent: tier is never a framing input' do
|
|
98
|
+
vector = fixture[:vectors][0]
|
|
99
|
+
instance_key = instance_key_class.new(provider_family: vector[:provider_family], instance_id: vector[:instance_id])
|
|
100
|
+
%i[local frontier].each do |_tier|
|
|
101
|
+
offering_id = described_class.offering_id(
|
|
102
|
+
instance_key: instance_key, provider_native_key: vector[:provider_native_key]
|
|
103
|
+
)
|
|
104
|
+
expect(offering_id).to eq(vector[:expected_offering_id])
|
|
105
|
+
expect(
|
|
106
|
+
described_class.lane_id(
|
|
107
|
+
instance_key: instance_key, operation: vector[:operation].to_sym,
|
|
108
|
+
model: vector[:model], offering_id: offering_id
|
|
109
|
+
)
|
|
110
|
+
).to eq(vector[:expected_lane_id])
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it 'frames punctuation inside a component without delimiter ambiguity' do
|
|
115
|
+
vector = fixture[:vectors][2]
|
|
116
|
+
instance_key = instance_key_class.new(provider_family: vector[:provider_family], instance_id: vector[:instance_id])
|
|
117
|
+
expect(instance_key.instance_id).to eq('prod:eastus')
|
|
118
|
+
expect(
|
|
119
|
+
described_class.offering_id(instance_key: instance_key, provider_native_key: vector[:provider_native_key])
|
|
120
|
+
).to eq(vector[:expected_offering_id])
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
describe '.length_frame' do
|
|
125
|
+
it 'is decimal byte length + ASCII colon + exact UTF-8 bytes, in binary' do
|
|
126
|
+
frame = described_class.length_frame(value: 'gemma4')
|
|
127
|
+
expect(frame.encoding).to eq(Encoding::BINARY)
|
|
128
|
+
expect(frame).to eq('6:gemma4'.b)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'counts UTF-8 bytes, not characters' do
|
|
132
|
+
composed = [0x00e9].pack('U*') # single codepoint => two UTF-8 bytes
|
|
133
|
+
expect(described_class.length_frame(value: composed)).to eq('2:'.b + composed.b)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
describe '.normalize_text' do
|
|
138
|
+
it 'accepts String and Symbol and returns a frozen UTF-8 String' do
|
|
139
|
+
result = described_class.normalize_text(value: :vllm, field: :provider_family)
|
|
140
|
+
expect(result).to eq('vllm')
|
|
141
|
+
expect(result).to be_frozen
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'trims surrounding whitespace' do
|
|
145
|
+
expect(described_class.normalize_text(value: ' h200 ', field: :instance_id)).to eq('h200')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'rejects empty and whitespace-only values' do
|
|
149
|
+
expect { described_class.normalize_text(value: ' ', field: :f) }.to raise_error(errors::ValidationError)
|
|
150
|
+
expect { described_class.normalize_text(value: '', field: :f) }.to raise_error(errors::ValidationError)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'rejects invalid UTF-8' do
|
|
154
|
+
expect { described_class.normalize_text(value: "\xFF".b, field: :f) }.to raise_error(errors::ValidationError)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it 'rejects a non-NFC (decomposed) string' do
|
|
158
|
+
decomposed = [0x0065, 0x0301].pack('U*') # e + combining acute; not NFC
|
|
159
|
+
expect { described_class.normalize_text(value: decomposed, field: :f) }
|
|
160
|
+
.to raise_error(errors::ValidationError, /NFC/)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it 'rejects a non-text type' do
|
|
164
|
+
expect { described_class.normalize_text(value: 5, field: :f) }.to raise_error(errors::ValidationError)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
describe '.normalize_enum' do
|
|
169
|
+
it 'returns an allowed symbol' do
|
|
170
|
+
expect(described_class.normalize_enum(value: 'local', field: :tier, allowed: %i[local frontier])).to eq(:local)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it 'rejects a value not in the allowed set' do
|
|
174
|
+
expect { described_class.normalize_enum(value: :nope, field: :tier, allowed: %i[local]) }
|
|
175
|
+
.to raise_error(errors::ValidationError)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
describe 'InstanceKey' do
|
|
180
|
+
it 'downcases provider_family to a Symbol and preserves instance_id case' do
|
|
181
|
+
key = instance_key_class.new(provider_family: 'Azure_Foundry', instance_id: 'Prod:EastUS')
|
|
182
|
+
expect(key.provider_family).to eq(:azure_foundry)
|
|
183
|
+
expect(key.instance_id).to eq('Prod:EastUS')
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it 'exposes to_h with the two fields' do
|
|
187
|
+
key = instance_key_class.new(provider_family: :vllm, instance_id: 'h200')
|
|
188
|
+
expect(key.to_h).to eq(provider_family: :vllm, instance_id: 'h200')
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'is value-equal for equal normalized fields' do
|
|
192
|
+
a = instance_key_class.new(provider_family: 'vllm', instance_id: 'h200')
|
|
193
|
+
b = instance_key_class.new(provider_family: :vllm, instance_id: ' h200 ')
|
|
194
|
+
expect(a).to eq(b)
|
|
195
|
+
expect(a.hash).to eq(b.hash)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it 'rejects a provider_family that is not snake-case ASCII' do
|
|
199
|
+
expect { instance_key_class.new(provider_family: '1bad', instance_id: 'x') }
|
|
200
|
+
.to raise_error(errors::ValidationError)
|
|
201
|
+
expect { instance_key_class.new(provider_family: 'has space', instance_id: 'x') }
|
|
202
|
+
.to raise_error(errors::ValidationError)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it 'rejects the reserved instance_id "default"' do
|
|
206
|
+
expect { instance_key_class.new(provider_family: 'vllm', instance_id: 'default') }
|
|
207
|
+
.to raise_error(errors::ValidationError)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it 'rejects nil fields' do
|
|
211
|
+
expect { instance_key_class.new(provider_family: nil, instance_id: 'x') }
|
|
212
|
+
.to raise_error(errors::ValidationError)
|
|
213
|
+
expect { instance_key_class.new(provider_family: 'vllm', instance_id: nil) }
|
|
214
|
+
.to raise_error(errors::ValidationError)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
describe 'public surface' do
|
|
219
|
+
it 'exposes exactly the seven documented module methods' do
|
|
220
|
+
expected = %i[
|
|
221
|
+
normalize_text normalize_enum length_frame offering_id lane_id
|
|
222
|
+
validate_offering_id! validate_lane_id!
|
|
223
|
+
].sort
|
|
224
|
+
expect(described_class.singleton_methods(false).sort).to eq(expected)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/inventory/immutable_value'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Llm::Inventory::ImmutableValue do
|
|
7
|
+
subject(:copy) { described_class.copy_and_freeze(value: value, field: :meta) }
|
|
8
|
+
|
|
9
|
+
describe 'scalar passthrough' do
|
|
10
|
+
it 'returns nil, booleans, symbols, and integers unchanged' do
|
|
11
|
+
[nil, true, false, :sym, 0, -5, 2**80].each do |scalar|
|
|
12
|
+
expect(described_class.copy_and_freeze(value: scalar, field: :f)).to eq(scalar)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'accepts finite floats' do
|
|
17
|
+
expect(described_class.copy_and_freeze(value: 1.5, field: :f)).to eq(1.5)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'rejects NaN' do
|
|
21
|
+
expect { described_class.copy_and_freeze(value: (0.0 / 0.0), field: :f) }
|
|
22
|
+
.to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, /f is not a finite Float/)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'rejects positive and negative infinity' do
|
|
26
|
+
expect { described_class.copy_and_freeze(value: (1.0 / 0.0), field: :f) }
|
|
27
|
+
.to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError)
|
|
28
|
+
expect { described_class.copy_and_freeze(value: (-1.0 / 0.0), field: :f) }
|
|
29
|
+
.to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe 'strings' do
|
|
34
|
+
let(:value) { +'hello' }
|
|
35
|
+
|
|
36
|
+
it 'returns a frozen copy' do
|
|
37
|
+
expect(copy).to eq('hello')
|
|
38
|
+
expect(copy).to be_frozen
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'does not retain the original mutable reference' do
|
|
42
|
+
original = +'mutable'
|
|
43
|
+
result = described_class.copy_and_freeze(value: original, field: :f)
|
|
44
|
+
expect(result).not_to equal(original)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'rejects invalid UTF-8' do
|
|
48
|
+
bad = "\xFF\xFE".b
|
|
49
|
+
expect { described_class.copy_and_freeze(value: bad, field: :f) }
|
|
50
|
+
.to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, /not valid UTF-8/)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe 'Time' do
|
|
55
|
+
let(:value) { Time.now }
|
|
56
|
+
|
|
57
|
+
it 'duplicates and freezes' do
|
|
58
|
+
original = Time.now
|
|
59
|
+
result = described_class.copy_and_freeze(value: original, field: :f)
|
|
60
|
+
expect(result).to eq(original)
|
|
61
|
+
expect(result).to be_frozen
|
|
62
|
+
expect(result).not_to equal(original)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
describe 'arrays and hashes' do
|
|
67
|
+
it 'deeply freezes nested arrays preserving order' do
|
|
68
|
+
result = described_class.copy_and_freeze(value: [3, [2, [1]]], field: :f)
|
|
69
|
+
expect(result).to eq([3, [2, [1]]])
|
|
70
|
+
expect(result).to be_frozen
|
|
71
|
+
expect(result[1]).to be_frozen
|
|
72
|
+
expect(result[1][1]).to be_frozen
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'deeply freezes nested hashes and preserves insertion order' do
|
|
76
|
+
result = described_class.copy_and_freeze(value: { b: 1, a: { c: [1] } }, field: :f)
|
|
77
|
+
expect(result.keys).to eq(%i[b a])
|
|
78
|
+
expect(result).to be_frozen
|
|
79
|
+
expect(result[:a]).to be_frozen
|
|
80
|
+
expect(result[:a][:c]).to be_frozen
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'accepts String and Symbol hash keys and freezes String keys' do
|
|
84
|
+
result = described_class.copy_and_freeze(value: { 'x' => 1, y: 2 }, field: :f)
|
|
85
|
+
expect(result.keys.map(&:frozen?)).to all(be(true))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'rejects a non-String/Symbol hash key' do
|
|
89
|
+
expect { described_class.copy_and_freeze(value: { 1 => :v }, field: :f) }
|
|
90
|
+
.to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, %r{non-String/Symbol key})
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'does not retain nested mutable references from the original' do
|
|
94
|
+
inner = +'inner'
|
|
95
|
+
original = { list: [inner] }
|
|
96
|
+
result = described_class.copy_and_freeze(value: original, field: :f)
|
|
97
|
+
inner << '-mutated'
|
|
98
|
+
expect(result[:list].first).to eq('inner')
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
describe 'rejections' do
|
|
103
|
+
it 'rejects an unsupported object' do
|
|
104
|
+
expect { described_class.copy_and_freeze(value: Object.new, field: :f) }
|
|
105
|
+
.to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, /unsupported value type/)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'rejects a callable' do
|
|
109
|
+
expect { described_class.copy_and_freeze(value: -> { 1 }, field: :f) }
|
|
110
|
+
.to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, /unsupported value type/)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'rejects a cyclic array naming the field path' do
|
|
114
|
+
cyclic = []
|
|
115
|
+
cyclic << cyclic
|
|
116
|
+
expect { described_class.copy_and_freeze(value: cyclic, field: :meta) }
|
|
117
|
+
.to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, /meta.*cyclic/)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'rejects a cyclic hash' do
|
|
121
|
+
cyclic = {}
|
|
122
|
+
cyclic[:self] = cyclic
|
|
123
|
+
expect { described_class.copy_and_freeze(value: cyclic, field: :meta) }
|
|
124
|
+
.to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError, /cyclic/)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'allows the same non-cyclic object referenced twice' do
|
|
128
|
+
shared = { k: 1 }
|
|
129
|
+
expect { described_class.copy_and_freeze(value: [shared, shared], field: :f) }.not_to raise_error
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/inventory/probe_coordinator'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Llm::Inventory::ProbeCoordinator do
|
|
7
|
+
inventory = Legion::Extensions::Llm::Inventory
|
|
8
|
+
errors = inventory::Errors
|
|
9
|
+
|
|
10
|
+
let(:instance_key) { inventory::Identity::InstanceKey.new(provider_family: 'vllm', instance_id: 'h200') }
|
|
11
|
+
let(:enqueued) { [] }
|
|
12
|
+
let(:enqueue_result) { [true] }
|
|
13
|
+
let(:enqueue) do
|
|
14
|
+
lambda { |request:|
|
|
15
|
+
enqueued << request
|
|
16
|
+
enqueue_result.first
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
let(:coordinator) { described_class.new(instance_key: instance_key, enqueue: enqueue) }
|
|
20
|
+
|
|
21
|
+
def enqueue_request(revision)
|
|
22
|
+
coordinator.enqueue_probe_request(
|
|
23
|
+
instance_key: instance_key, publisher_token_id: 'ptok:v1:x', unavailable_revision: revision, reason: 'dispatch unavailable'
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'does not invoke enqueue at construction (enqueue-only)' do
|
|
28
|
+
coordinator
|
|
29
|
+
expect(enqueued).to be_empty
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'enqueues a ProbeRequest built from the reported parameters' do
|
|
33
|
+
enqueue_request(1)
|
|
34
|
+
expect(enqueued.size).to eq(1)
|
|
35
|
+
expect(enqueued.first).to be_a(inventory::ProbeRequest)
|
|
36
|
+
expect(enqueued.first.unavailable_revision).to eq(1)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'rejects a request for a different instance' do
|
|
40
|
+
other = inventory::Identity::InstanceKey.new(provider_family: 'vllm', instance_id: 'helios1')
|
|
41
|
+
expect do
|
|
42
|
+
coordinator.enqueue_probe_request(instance_key: other, publisher_token_id: 'ptok:v1:x', unavailable_revision: 1, reason: 'x')
|
|
43
|
+
end.to raise_error(errors::ValidationError)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'coalesces duplicate-revision requests to a single enqueue' do
|
|
47
|
+
enqueue_request(5)
|
|
48
|
+
enqueue_request(5)
|
|
49
|
+
expect(enqueued.size).to eq(1)
|
|
50
|
+
expect(coordinator.pending?(unavailable_revision: 5)).to be(true)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'retains only the greatest pending revision without re-enqueuing' do
|
|
54
|
+
enqueue_request(5)
|
|
55
|
+
enqueue_request(7)
|
|
56
|
+
expect(enqueued.size).to eq(1)
|
|
57
|
+
expect(coordinator.pending?(unavailable_revision: 7)).to be(true)
|
|
58
|
+
expect(coordinator.pending?(unavailable_revision: 5)).to be(false)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'retains pending and returns false when enqueue fails, without inline readiness' do
|
|
62
|
+
enqueue_result[0] = false
|
|
63
|
+
expect(enqueue_request(3)).to be(false)
|
|
64
|
+
expect(coordinator.pending?(unavailable_revision: 3)).to be(true)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
describe 'single flight and post-finish re-enqueue' do
|
|
68
|
+
it 'admits one flight, holds newer work, and re-enqueues the greatest pending on finish' do
|
|
69
|
+
enqueue_request(5)
|
|
70
|
+
first = enqueued.last
|
|
71
|
+
expect(coordinator.begin_probe(request: first)).to be(true)
|
|
72
|
+
expect(coordinator.in_flight?).to be(true)
|
|
73
|
+
|
|
74
|
+
# A newer revision arrives during the flight: retained, not enqueued.
|
|
75
|
+
enqueue_request(7)
|
|
76
|
+
expect(enqueued.size).to eq(1)
|
|
77
|
+
|
|
78
|
+
coordinator.finish_probe(request: first)
|
|
79
|
+
expect(coordinator.in_flight?).to be(false)
|
|
80
|
+
expect(enqueued.size).to eq(2)
|
|
81
|
+
expect(enqueued.last.unavailable_revision).to eq(7)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'rejects a second begin while a flight is active' do
|
|
85
|
+
enqueue_request(5)
|
|
86
|
+
expect(coordinator.begin_probe(request: enqueued.last)).to be(true)
|
|
87
|
+
expect(coordinator.begin_probe(request: nil)).to be(false)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'supports the cadence (nil) begin/finish form and rejects a mismatched finish form' do
|
|
91
|
+
mismatched = inventory::ProbeRequest.new(
|
|
92
|
+
instance_key: instance_key, publisher_token_id: 'ptok:v1:x', unavailable_revision: 1, reason: 'x'
|
|
93
|
+
)
|
|
94
|
+
expect(coordinator.begin_probe(request: nil)).to be(true)
|
|
95
|
+
expect { coordinator.finish_probe(request: mismatched) }.to raise_error(errors::InvalidTransitionError)
|
|
96
|
+
expect { coordinator.finish_probe(request: nil) }.not_to raise_error
|
|
97
|
+
expect(coordinator.in_flight?).to be(false)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'only admits a begin for the exact greatest pending request' do
|
|
101
|
+
enqueue_request(5)
|
|
102
|
+
stale = enqueued.last
|
|
103
|
+
enqueue_request(9) # replaces pending; stale is no longer greatest
|
|
104
|
+
expect(coordinator.begin_probe(request: stale)).to be(false)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|