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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/inventory/callable_handle'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Llm::Inventory::CallableHandle do
|
|
7
|
+
let(:errors) { Legion::Extensions::Llm::Inventory::Errors }
|
|
8
|
+
|
|
9
|
+
# Minimal callable recording exactly-once disconnect; no registry/provider hooks.
|
|
10
|
+
let(:callable_class) do
|
|
11
|
+
Class.new do
|
|
12
|
+
attr_reader :disconnect_count
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@disconnect_count = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def disconnect
|
|
19
|
+
@disconnect_count += 1
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
let(:callable) { callable_class.new }
|
|
25
|
+
let(:handle) { described_class.new(handle_id: 'call:v1:test', callable: callable) }
|
|
26
|
+
|
|
27
|
+
describe 'construction' do
|
|
28
|
+
it 'starts ACTIVE with zero references' do
|
|
29
|
+
expect(handle.state).to eq(:active)
|
|
30
|
+
expect(handle.reference_count).to eq(0)
|
|
31
|
+
expect(handle.handle_id).to eq('call:v1:test')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'rejects an empty handle_id and a nil callable' do
|
|
35
|
+
expect { described_class.new(handle_id: '', callable: callable) }.to raise_error(errors::ValidationError)
|
|
36
|
+
expect { described_class.new(handle_id: 'call:v1:x', callable: nil) }.to raise_error(errors::ValidationError)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe '#acquire' do
|
|
41
|
+
it 'increments the reference count and returns a lease exposing the exact callable' do
|
|
42
|
+
lease = handle.acquire
|
|
43
|
+
expect(handle.reference_count).to eq(1)
|
|
44
|
+
expect(lease.callable).to equal(callable)
|
|
45
|
+
expect(lease).not_to be_released
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'returns independent leases with distinct ids' do
|
|
49
|
+
a = handle.acquire
|
|
50
|
+
b = handle.acquire
|
|
51
|
+
expect(handle.reference_count).to eq(2)
|
|
52
|
+
expect(a.lease_id).not_to eq(b.lease_id)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'does not disconnect while leases are released back (error/cancellation/stream-close simulation)' do
|
|
56
|
+
lease = handle.acquire
|
|
57
|
+
lease.release
|
|
58
|
+
expect(lease).to be_released
|
|
59
|
+
expect(handle.reference_count).to eq(0)
|
|
60
|
+
expect(callable.disconnect_count).to eq(0)
|
|
61
|
+
expect(handle.state).to eq(:active)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe '#retire' do
|
|
66
|
+
it 'disposes immediately and disconnects once when idle' do
|
|
67
|
+
expect(handle.retire).to eq(:disposed)
|
|
68
|
+
expect(handle.state).to eq(:disposed)
|
|
69
|
+
expect(callable.disconnect_count).to eq(1)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'moves to RETIRING while a lease is outstanding and blocks new acquisition' do
|
|
73
|
+
lease = handle.acquire
|
|
74
|
+
expect(handle.retire).to eq(:retiring)
|
|
75
|
+
expect(handle.state).to eq(:retiring)
|
|
76
|
+
expect { handle.acquire }.to raise_error(errors::StaleCallableError)
|
|
77
|
+
expect(callable.disconnect_count).to eq(0)
|
|
78
|
+
|
|
79
|
+
lease.release
|
|
80
|
+
expect(handle.state).to eq(:disposed)
|
|
81
|
+
expect(callable.disconnect_count).to eq(1)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'raises CallableDisposedError on acquire after disposal' do
|
|
85
|
+
handle.retire
|
|
86
|
+
expect { handle.acquire }.to raise_error(errors::CallableDisposedError)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'disconnects exactly once even with multiple leases' do
|
|
90
|
+
a = handle.acquire
|
|
91
|
+
b = handle.acquire
|
|
92
|
+
handle.retire
|
|
93
|
+
a.release
|
|
94
|
+
expect(callable.disconnect_count).to eq(0)
|
|
95
|
+
b.release
|
|
96
|
+
expect(callable.disconnect_count).to eq(1)
|
|
97
|
+
expect(handle.state).to eq(:disposed)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
describe 'release safety' do
|
|
102
|
+
it 'raises on duplicate release and does not decrement twice' do
|
|
103
|
+
a = handle.acquire
|
|
104
|
+
b = handle.acquire
|
|
105
|
+
a.release
|
|
106
|
+
expect { a.release }.to raise_error(errors::InvalidTransitionError)
|
|
107
|
+
expect(handle.reference_count).to eq(1)
|
|
108
|
+
b.release
|
|
109
|
+
expect(handle.reference_count).to eq(0)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
describe 'disconnect failure' do
|
|
114
|
+
let(:callable_class) do
|
|
115
|
+
Class.new do
|
|
116
|
+
def disconnect
|
|
117
|
+
raise StandardError, 'boom'
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'still becomes DISPOSED and returns :disposed_with_error without raising' do
|
|
123
|
+
expect(handle.retire).to eq(:disposed_with_error)
|
|
124
|
+
expect(handle.state).to eq(:disposed)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
|
|
96
|
+
it 'never lets config/override sources carry :supported (enable_* is consumed router-side, not published)' do
|
|
97
|
+
%i[model_override instance_override provider_override].each do |source|
|
|
98
|
+
expect { capability_evidence.new(capability: :tools, status: :supported, source: source) }
|
|
99
|
+
.to raise_error(errors::ValidationError)
|
|
100
|
+
expect { capability_evidence.new(capability: :thinking, status: :supported, source: source) }
|
|
101
|
+
.to raise_error(errors::ValidationError)
|
|
102
|
+
expect { operation_evidence.new(operation: :chat, status: :supported, source: source) }
|
|
103
|
+
.to raise_error(errors::ValidationError)
|
|
104
|
+
expect { capability_evidence.new(capability: :tools, status: :unknown, source: source) }
|
|
105
|
+
.not_to raise_error
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
describe 'ValueEvidence' do
|
|
111
|
+
it 'requires a non-nil value when known and copies/freezes it' do
|
|
112
|
+
ev = value_evidence.new(status: :known, value: 128_000, source: :model_metadata)
|
|
113
|
+
expect(ev).to be_known
|
|
114
|
+
expect(ev.value).to eq(128_000)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'freezes a known collection value' do
|
|
118
|
+
ev = value_evidence.new(status: :known, value: [512, 1024], source: :provider_catalog)
|
|
119
|
+
expect(ev.value).to be_frozen
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'rejects a known status with a nil value' do
|
|
123
|
+
expect { value_evidence.new(status: :known, value: nil, source: :model_metadata) }
|
|
124
|
+
.to raise_error(errors::ValidationError)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'requires nil value when unknown' do
|
|
128
|
+
expect { value_evidence.new(status: :unknown, value: 5, source: :absent) }
|
|
129
|
+
.to raise_error(errors::ValidationError)
|
|
130
|
+
expect(value_evidence.new(status: :unknown, source: :absent)).to be_unknown
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'enforces the unknown-only source rule' do
|
|
134
|
+
expect { value_evidence.new(status: :known, value: 10, source: :guessed) }
|
|
135
|
+
.to raise_error(errors::ValidationError)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,280 @@
|
|
|
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 three fields (physical_id nil by default)' 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', physical_id: nil)
|
|
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
|
+
|
|
217
|
+
it 'keeps the two-argument constructor working (physical_id defaults to nil)' do
|
|
218
|
+
key = instance_key_class.new(provider_family: :vllm, instance_id: 'apollo')
|
|
219
|
+
expect(key.physical_id).to be_nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
describe 'physical_id (secondary, not identity)' do
|
|
223
|
+
it 'normalizes like identity text: trims, NFC, frozen String' do
|
|
224
|
+
key = instance_key_class.new(provider_family: :vllm, instance_id: 'apollo', physical_id: ' 10.0.0.1:8000/ak:abc123 ')
|
|
225
|
+
expect(key.physical_id).to eq('10.0.0.1:8000/ak:abc123')
|
|
226
|
+
expect(key.physical_id).to be_frozen
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it 'preserves the physical id for diagnostics while identity stays the config name' do
|
|
230
|
+
key = instance_key_class.new(
|
|
231
|
+
provider_family: :vllm, instance_id: 'apollo', physical_id: '10.0.0.1:8000/ak:abc123'
|
|
232
|
+
)
|
|
233
|
+
expect(key.instance_id).to eq('apollo')
|
|
234
|
+
expect(key.physical_id).to eq('10.0.0.1:8000/ak:abc123')
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it 'excludes physical_id from equality and hashing' do
|
|
238
|
+
bare = instance_key_class.new(provider_family: :vllm, instance_id: 'apollo')
|
|
239
|
+
with_physical = instance_key_class.new(
|
|
240
|
+
provider_family: :vllm, instance_id: 'apollo', physical_id: '10.0.0.1:8000/ak:abc123'
|
|
241
|
+
)
|
|
242
|
+
other_physical = instance_key_class.new(
|
|
243
|
+
provider_family: :vllm, instance_id: 'apollo', physical_id: '10.0.0.9:9000'
|
|
244
|
+
)
|
|
245
|
+
expect(bare).to eq(with_physical)
|
|
246
|
+
expect(bare.hash).to eq(with_physical.hash)
|
|
247
|
+
expect(with_physical).to eq(other_physical)
|
|
248
|
+
expect({ bare => :found }[with_physical]).to eq(:found)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it 'does not reserve "default" for physical_id (only instance_id is reserved)' do
|
|
252
|
+
key = instance_key_class.new(provider_family: :vllm, instance_id: 'apollo', physical_id: 'default')
|
|
253
|
+
expect(key.physical_id).to eq('default')
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it 'rejects a blank or non-text physical_id' do
|
|
257
|
+
expect { instance_key_class.new(provider_family: 'vllm', instance_id: 'x', physical_id: ' ') }
|
|
258
|
+
.to raise_error(errors::ValidationError)
|
|
259
|
+
expect { instance_key_class.new(provider_family: 'vllm', instance_id: 'x', physical_id: 5) }
|
|
260
|
+
.to raise_error(errors::ValidationError)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it 'still distinguishes distinct config names on the same physical endpoint' do
|
|
264
|
+
apollo = instance_key_class.new(provider_family: :ollama, instance_id: 'apollo', physical_id: 'localhost:11434')
|
|
265
|
+
apollo_embed = instance_key_class.new(provider_family: :ollama, instance_id: 'apollo-embed', physical_id: 'localhost:11434')
|
|
266
|
+
expect(apollo).not_to eq(apollo_embed)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
describe 'public surface' do
|
|
272
|
+
it 'exposes exactly the seven documented module methods' do
|
|
273
|
+
expected = %i[
|
|
274
|
+
normalize_text normalize_enum length_frame offering_id lane_id
|
|
275
|
+
validate_offering_id! validate_lane_id!
|
|
276
|
+
].sort
|
|
277
|
+
expect(described_class.singleton_methods(false).sort).to eq(expected)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
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
|