lex-llm 0.7.3 → 0.8.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 +106 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
- data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
- data/lib/legion/extensions/llm/canonical/message.rb +52 -67
- data/lib/legion/extensions/llm/canonical/params.rb +59 -33
- data/lib/legion/extensions/llm/canonical/request.rb +67 -54
- data/lib/legion/extensions/llm/canonical/response.rb +61 -76
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
- data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
- data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
- data/lib/legion/extensions/llm/canonical.rb +6 -5
- data/lib/legion/extensions/llm/configuration.rb +40 -10
- data/lib/legion/extensions/llm/connection.rb +8 -30
- data/lib/legion/extensions/llm/credential_sources.rb +32 -49
- data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
- data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
- data/lib/legion/extensions/llm/error.rb +0 -14
- data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
- data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
- data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
- data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
- data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
- data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
- data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
- data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
- data/lib/legion/extensions/llm/inventory/records.rb +82 -99
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
- data/lib/legion/extensions/llm/provider.rb +192 -315
- data/lib/legion/extensions/llm/provider_contract.rb +25 -8
- data/lib/legion/extensions/llm/provider_settings.rb +5 -26
- data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
- data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
- data/lib/legion/extensions/llm/routing/records.rb +34 -17
- data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
- data/lib/legion/extensions/llm/streaming.rb +50 -35
- data/lib/legion/extensions/llm/taxonomies.rb +29 -17
- data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
- data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
- data/lib/legion/extensions/llm/utils.rb +23 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +10 -98
- data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
- data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
- data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
- data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
- data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
- data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
- data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
- data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
- data/spec/legion/extensions/llm/error_spec.rb +2 -12
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
- data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
- data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
- data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
- data/spec/legion/extensions/llm/provider_spec.rb +98 -78
- data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
- data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
- data/spec/legion/extensions/llm/utils_spec.rb +26 -7
- data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
- data/spec/legion/extensions/llm_extension_spec.rb +5 -5
- data/spec/support/fake_llm_provider.rb +45 -39
- data/spec/support/fake_ssot_harness.rb +7 -2
- data/spec/support/ssot_registry_helpers.rb +3 -2
- metadata +15 -54
- data/lib/legion/extensions/llm/agent.rb +0 -366
- data/lib/legion/extensions/llm/aliases.json +0 -436
- data/lib/legion/extensions/llm/aliases.rb +0 -67
- data/lib/legion/extensions/llm/attachment.rb +0 -229
- data/lib/legion/extensions/llm/chat.rb +0 -354
- data/lib/legion/extensions/llm/chunk.rb +0 -10
- data/lib/legion/extensions/llm/content.rb +0 -81
- data/lib/legion/extensions/llm/context.rb +0 -33
- data/lib/legion/extensions/llm/embedding.rb +0 -33
- data/lib/legion/extensions/llm/image.rb +0 -109
- data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
- data/lib/legion/extensions/llm/message.rb +0 -118
- data/lib/legion/extensions/llm/mime_type.rb +0 -75
- data/lib/legion/extensions/llm/model/info.rb +0 -286
- data/lib/legion/extensions/llm/model/modalities.rb +0 -26
- data/lib/legion/extensions/llm/model/pricing.rb +0 -52
- data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
- data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
- data/lib/legion/extensions/llm/model.rb +0 -11
- data/lib/legion/extensions/llm/models.json +0 -57313
- data/lib/legion/extensions/llm/models.rb +0 -530
- data/lib/legion/extensions/llm/models_schema.json +0 -168
- data/lib/legion/extensions/llm/moderation.rb +0 -60
- data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
- data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
- data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
- data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
- data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
- data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
- data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
- data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
- data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
- data/lib/legion/extensions/llm/thinking.rb +0 -53
- data/lib/legion/extensions/llm/tokens.rb +0 -51
- data/lib/legion/extensions/llm/tool_call.rb +0 -34
- data/lib/legion/extensions/llm/transcription.rb +0 -39
- data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
- data/spec/legion/extensions/llm/agent_spec.rb +0 -179
- data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
- data/spec/legion/extensions/llm/context_spec.rb +0 -127
- data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
- data/spec/legion/extensions/llm/message_spec.rb +0 -64
- data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
- data/spec/legion/extensions/llm/models_spec.rb +0 -104
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# 09 §2–§4 — boundary (B), fleet (F), and registry (R) conformance shared
|
|
4
|
+
# examples. The kit is the single oracle: provider gems load these and run
|
|
5
|
+
# them against their REAL callable boundary and the shared Registry/Responder.
|
|
6
|
+
#
|
|
7
|
+
# The host spec provides:
|
|
8
|
+
# let(:callable) — a 0.8.0 callable: chat(messages, model:, ...),
|
|
9
|
+
# stream_chat(messages, model:, ...) yielding
|
|
10
|
+
# Canonical::Chunk, count_tokens(messages:, model:)
|
|
11
|
+
# let(:registry) — Legion::Extensions::Llm::Inventory::Registry
|
|
12
|
+
# let(:key) — an InstanceKey for the activated scope
|
|
13
|
+
# let(:lane_id) — a 5-tuple lane id on the activated instance
|
|
14
|
+
# (tier:provider_family:instance_id:type:model)
|
|
15
|
+
# let(:callable_handle) — the activated instance's CallableHandle (B6)
|
|
16
|
+
# let(:activated) — true once the scope is activated (F/R groups)
|
|
17
|
+
|
|
18
|
+
# The kit's single operation-evidence builder: one supported operation, the
|
|
19
|
+
# rest unknown. Kept here so every host resolves the 5-tuple type the same way.
|
|
20
|
+
module KitEvidenceSupport
|
|
21
|
+
def kit_operation_evidence(operation)
|
|
22
|
+
LLM::Taxonomies::OPERATIONS.to_h do |op|
|
|
23
|
+
status, source = op == operation ? %i[supported provider_implementation] : %i[unknown absent]
|
|
24
|
+
[op, INV::OperationEvidence.new(operation: op, status: status, source: source)]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
LLM = Legion::Extensions::Llm
|
|
30
|
+
CAN = LLM::Canonical
|
|
31
|
+
INV = LLM::Inventory
|
|
32
|
+
|
|
33
|
+
RSpec.shared_examples 'B1 — central canonical enforcement (08 F2)' do
|
|
34
|
+
let(:canonical_messages) { [CAN::Message.build(role: :user, content: 'hi')] }
|
|
35
|
+
|
|
36
|
+
[
|
|
37
|
+
['a raw Hash message', [{ role: 'user', content: 'x' }]],
|
|
38
|
+
['a String element', ['not canonical']],
|
|
39
|
+
['a nil element', [nil]]
|
|
40
|
+
].each do |label, bad_messages|
|
|
41
|
+
it "chat rejects #{label} with a typed ArgumentError" do
|
|
42
|
+
expect { callable.chat(bad_messages, model: 'm') }
|
|
43
|
+
.to raise_error(ArgumentError, /Canonical::Message/)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "stream_chat rejects #{label} with a typed ArgumentError" do
|
|
47
|
+
# rubocop:disable Lint/EmptyBlock -- the block never runs: enforcement raises first
|
|
48
|
+
expect { callable.stream_chat(bad_messages, model: 'm') { |_c| } }
|
|
49
|
+
.to raise_error(ArgumentError, /Canonical::Message/)
|
|
50
|
+
# rubocop:enable Lint/EmptyBlock
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "count_tokens rejects #{label} with a typed ArgumentError" do
|
|
54
|
+
expect { callable.count_tokens(messages: bad_messages, model: 'm') }
|
|
55
|
+
.to raise_error(ArgumentError, /Canonical::Message/)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'accepts a canonical Array<Canonical::Message>' do
|
|
60
|
+
expect { callable.chat(canonical_messages, model: 'm') }.not_to raise_error
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# H3: the tools half of the same boundary — one contract, one oracle.
|
|
65
|
+
# A non-empty tools value must be Hash<name, Canonical::ToolDefinition>;
|
|
66
|
+
# Hash/legacy tool values raise before any provider rendering.
|
|
67
|
+
RSpec.shared_examples 'B1b — central canonical tool enforcement (H3)' do
|
|
68
|
+
let(:tool) { CAN::ToolDefinition.build(name: 'get_weather') }
|
|
69
|
+
|
|
70
|
+
it 'chat accepts Hash<name, Canonical::ToolDefinition>' do
|
|
71
|
+
expect do
|
|
72
|
+
callable.chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm',
|
|
73
|
+
tools: { get_weather: tool })
|
|
74
|
+
end.not_to raise_error
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'chat rejects Hash tool values with a typed ArgumentError' do
|
|
78
|
+
expect do
|
|
79
|
+
callable.chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm',
|
|
80
|
+
tools: { get_weather: { name: 'get_weather' } })
|
|
81
|
+
end
|
|
82
|
+
.to raise_error(ArgumentError, /Canonical::ToolDefinition/)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'chat rejects a non-Hash tools container with a typed ArgumentError' do
|
|
86
|
+
expect do
|
|
87
|
+
callable.chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm',
|
|
88
|
+
tools: [tool])
|
|
89
|
+
end
|
|
90
|
+
.to raise_error(ArgumentError, /Hash<name, Canonical::ToolDefinition>/)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
RSpec.shared_examples 'B2 — canonical outputs (05 O5, 08 R2)' do
|
|
95
|
+
it 'chat returns a Canonical::Response (asserted by type)' do
|
|
96
|
+
expect(callable.chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm'))
|
|
97
|
+
.to be_a(CAN::Response)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'stream_chat yields Canonical::Chunk objects ending in exactly one done chunk' do
|
|
101
|
+
chunks = []
|
|
102
|
+
callable.stream_chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm') { |c| chunks << c }
|
|
103
|
+
|
|
104
|
+
expect(chunks.size).to be > 0
|
|
105
|
+
expect(chunks).to all(be_a(CAN::Chunk))
|
|
106
|
+
expect(chunks.count(&:done?)).to eq(1)
|
|
107
|
+
expect(chunks.last).to be_done
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
RSpec.shared_examples 'B3 — operation preservation (PR #189 defect class)' do
|
|
112
|
+
it 'a stream_chat dispatch invokes the stream_chat callable, never chat' do
|
|
113
|
+
chat_invocations = 0
|
|
114
|
+
stream_invocations = 0
|
|
115
|
+
allow(callable).to receive(:chat).and_wrap_original do |original, *args, **kwargs|
|
|
116
|
+
chat_invocations += 1
|
|
117
|
+
original.call(*args, **kwargs)
|
|
118
|
+
end
|
|
119
|
+
allow(callable).to receive(:stream_chat).and_wrap_original do |_original, *_args, &block|
|
|
120
|
+
stream_invocations += 1
|
|
121
|
+
block&.call(CAN::Chunk.done(request_id: nil))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# rubocop:disable Lint/EmptyBlock -- the block is the assertion target
|
|
125
|
+
callable.stream_chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm') { |_c| }
|
|
126
|
+
# rubocop:enable Lint/EmptyBlock
|
|
127
|
+
|
|
128
|
+
expect(stream_invocations).to eq(1)
|
|
129
|
+
expect(chat_invocations).to eq(0)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
RSpec.shared_examples 'B4 — no model re-derivation (PR #45 law)' do
|
|
134
|
+
it 'the Selection-derived model reaches the callable unchanged' do
|
|
135
|
+
response = callable.chat([CAN::Message.build(role: :user, content: 'hi')], model: 'selected-model-x')
|
|
136
|
+
expect(response.model).to eq('selected-model-x')
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
RSpec.shared_examples 'B5 — no weight recomputation (PR #47 defect class)' do
|
|
141
|
+
include KitEvidenceSupport
|
|
142
|
+
|
|
143
|
+
it 'the lane keeps the write-time pair even when settings would compute a different one' do
|
|
144
|
+
lane = registry.snapshot.lanes_for(instance_key: key).first
|
|
145
|
+
expect(lane.base_weight).to eq(100 * 50 * 2 * 1)
|
|
146
|
+
|
|
147
|
+
# A later settings read (e.g. after an operator change) would compute a
|
|
148
|
+
# different pair — the stored lane pair is consumed as-is.
|
|
149
|
+
family = key.provider_family.to_sym
|
|
150
|
+
other_inputs = LLM::Inventory.const_get(:WeightSchema).weight_inputs(
|
|
151
|
+
settings: { extensions: { llm: { family => { weight: 77, instances: { key.instance_id.to_sym => { weight: 77 } } } } },
|
|
152
|
+
llm: { routing: { tier_weights: { local: 100 } } } },
|
|
153
|
+
instance_key: key, model: lane.model, tier: :local,
|
|
154
|
+
operation_evidence: kit_operation_evidence(lane.operation)
|
|
155
|
+
)
|
|
156
|
+
expect(other_inputs).to eq(tier: 100, provider: 77, instance: 77, model_or_offering: 100)
|
|
157
|
+
expect(registry.snapshot.lanes_for(instance_key: key).first.base_weight).to eq(10_000)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
RSpec.shared_examples 'B6 — zero-weight disable (U4)' do
|
|
162
|
+
it 'Selection validation accepts a zero component (operator disable)' do
|
|
163
|
+
selection = LLM::Routing::Selection.new(
|
|
164
|
+
inventory_generation: 7, lane_id: lane_id, instance_key: instance_key,
|
|
165
|
+
provider_family: instance_key.provider_family, instance_id: instance_key.instance_id,
|
|
166
|
+
model: 'gemma4', operation: :chat, callable_handle: callable_handle,
|
|
167
|
+
publisher_token_id: 'ptok:v1:abc', capability_evidence: {},
|
|
168
|
+
context_evidence: INV::ValueEvidence.new(status: :unknown, source: :absent),
|
|
169
|
+
weight_inputs: { tier: 100, provider: 0, instance: 100, model_or_offering: 100 },
|
|
170
|
+
base_weight: 0, preference_ppm: 1_000_000, effective_weight: 0, rendezvous_score: 1
|
|
171
|
+
)
|
|
172
|
+
expect(selection.base_weight).to eq(0)
|
|
173
|
+
expect(selection.weight_inputs[:provider]).to eq(0)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'Selection validation still rejects a negative component' do
|
|
177
|
+
expect do
|
|
178
|
+
LLM::Routing::Selection.new(
|
|
179
|
+
inventory_generation: 7, lane_id: lane_id, instance_key: instance_key,
|
|
180
|
+
provider_family: instance_key.provider_family, instance_id: instance_key.instance_id,
|
|
181
|
+
model: 'gemma4', operation: :chat, callable_handle: callable_handle,
|
|
182
|
+
publisher_token_id: 'ptok:v1:abc', capability_evidence: {},
|
|
183
|
+
context_evidence: INV::ValueEvidence.new(status: :unknown, source: :absent),
|
|
184
|
+
weight_inputs: { tier: 100, provider: -1, instance: 100, model_or_offering: 100 },
|
|
185
|
+
base_weight: 0, preference_ppm: 1_000_000, effective_weight: 0, rendezvous_score: 1
|
|
186
|
+
)
|
|
187
|
+
end.to raise_error(INV::Errors::ValidationError, /nonnegative/)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
RSpec.shared_examples 'B7 — health authority (RULES 9)' do
|
|
192
|
+
it 'transient provider outcomes do not change instance availability' do
|
|
193
|
+
outcome = callable.normalize_dispatch_error(error: LLM::RateLimitError.new(nil, 'slow down'))
|
|
194
|
+
expect(outcome.kind).to eq(:rate_limited)
|
|
195
|
+
expect(outcome.kind).not_to eq(:instance_unavailable)
|
|
196
|
+
expect(registry.snapshot.instance(instance_key: key).availability.state).to eq(:available)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it 'only the authoritative dispatch path marks the instance unavailable' do
|
|
200
|
+
health_key = INV::Identity::InstanceKey.new(provider_family: key.provider_family, instance_id: 'health-b')
|
|
201
|
+
health_coordinator = LLM::Inventory::ProbeCoordinator.new(instance_key: health_key, enqueue: ->(**) { true })
|
|
202
|
+
token = registry.claim_instance(instance_key: health_key, callable: Object.new, probe_request_handle: health_coordinator)
|
|
203
|
+
probe = registry.readiness_probe_started(instance_key: health_key, publisher_token: token)
|
|
204
|
+
registry.activate_instance_snapshot(publisher_token: token, instance_key: health_key,
|
|
205
|
+
offerings: drafts, sequence: 0, probe_token: probe)
|
|
206
|
+
record = registry.snapshot.instance(instance_key: health_key)
|
|
207
|
+
registry.dispatch_instance_unavailable(
|
|
208
|
+
instance_key: health_key, publisher_token_id: record.publisher_token_id, reason: 'node down'
|
|
209
|
+
)
|
|
210
|
+
expect(registry.snapshot.instance(instance_key: health_key).availability.state).to eq(:unavailable)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
RSpec.shared_examples 'B8 — exact execution never downgrades (06 P2/W1)' do
|
|
215
|
+
it 'a fleet envelope without the exact marker is rejected' do
|
|
216
|
+
legacy_envelope = LLM::Fleet::FleetEnvelope.new(
|
|
217
|
+
data: exact_envelope_data.except(:execution_contract, :offering_id)
|
|
218
|
+
)
|
|
219
|
+
expect { LLM::Fleet::ProviderResponder.check_envelope!(legacy_envelope, provider_family: key.provider_family.to_s) }
|
|
220
|
+
.to raise_error(ArgumentError, /execution_contract is required/)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it 'the provider-object dispatch path does not exist' do
|
|
224
|
+
expect(LLM::Fleet::ProviderResponder).not_to respond_to(:build_provider)
|
|
225
|
+
expect(LLM::Fleet::WorkerExecution).not_to respond_to(:dispatch_local_provider!)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
RSpec.shared_examples 'B9 — no silent defaults (06 W5)' do
|
|
230
|
+
it 'a missing per-operation param raises, never a default' do
|
|
231
|
+
expect { LLM::Fleet::WorkerExecution.call(envelope: exact_envelope_data.merge(params: {}), registry: registry) }
|
|
232
|
+
.to raise_error(LLM::Fleet::ContractError, /requires the messages param/)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it 'a params-supplied model raises — the Selection model is the only model' do
|
|
236
|
+
bad = exact_envelope_data.merge(params: { messages: [], model: 'other' })
|
|
237
|
+
expect { LLM::Fleet::WorkerExecution.call(envelope: bad, registry: registry) }
|
|
238
|
+
.to raise_error(LLM::Fleet::ContractError, /must not contain model/)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
RSpec.shared_examples 'F1 — envelope round-trip (06 E2)' do
|
|
243
|
+
it 'FleetRequest encode → JSON → parse carries every required field' do
|
|
244
|
+
request = LLM::Transport::Messages::FleetRequest.new(**valid_request_options)
|
|
245
|
+
wire = Legion::JSON.load(Legion::JSON.dump(request.message))
|
|
246
|
+
|
|
247
|
+
LLM::Fleet::Protocol::REQUIRED_FIELDS.each do |field|
|
|
248
|
+
expect(wire).to have_key(field)
|
|
249
|
+
# JSON normalizes the wire: compare in wire form (symbols become strings).
|
|
250
|
+
expect(wire[field].is_a?(Hash) ? wire[field].transform_keys(&:to_s) : wire[field].to_s)
|
|
251
|
+
.to eq(request.message[field].is_a?(Hash) ? request.message[field].transform_keys(&:to_s) : request.message[field].to_s)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
RSpec.shared_examples 'F2 — rehydration identity (06 W4, E01)' do
|
|
257
|
+
it 'JSON-round-tripped wire messages rehydrate to Data-equal Canonical::Message' do
|
|
258
|
+
original = CAN::Message.build(
|
|
259
|
+
role: :user, content: 'hi', cache_control: { type: :ephemeral },
|
|
260
|
+
tool_calls: [CAN::ToolCall.build(name: 'f', id: 'c1', arguments: { a: 1 })]
|
|
261
|
+
)
|
|
262
|
+
wire = Legion::JSON.load(Legion::JSON.dump([original.to_h]))
|
|
263
|
+
rehydrated = LLM::Fleet::WorkerExecution.rehydrate_wire_messages(wire)
|
|
264
|
+
|
|
265
|
+
expect(rehydrated.size).to eq(1)
|
|
266
|
+
rehydrated_message = rehydrated.first
|
|
267
|
+
expect(rehydrated_message).to be_a(CAN::Message)
|
|
268
|
+
expect(rehydrated_message.id).to eq(original.id)
|
|
269
|
+
expect(rehydrated_message.role).to eq(:user)
|
|
270
|
+
expect(rehydrated_message.content).to eq('hi')
|
|
271
|
+
expect(rehydrated_message.tool_calls.first.name).to eq('f')
|
|
272
|
+
expect(rehydrated_message.tool_calls.first.arguments).to eq(a: 1)
|
|
273
|
+
# E01: cache_control survives the full JSON wire round-trip.
|
|
274
|
+
expect(rehydrated_message.cache_control).to eq(type: 'ephemeral')
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
it 'a raw Hash message can NOT reach a callable — rehydration raises' do
|
|
278
|
+
expect { LLM::Fleet::WorkerExecution.rehydrate_wire_messages(['not a message']) }
|
|
279
|
+
.to raise_error(LLM::Fleet::ContractError, /serialized Canonical::Message/)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
RSpec.shared_examples 'F3 — signing law (06 S3)' do
|
|
284
|
+
it 'verifies exact claims unconditionally — a missing marker claim is a TokenError' do
|
|
285
|
+
expect do
|
|
286
|
+
LLM::Fleet::TokenValidator.validate_exact_execution_claims!({}, { operation: 'chat' })
|
|
287
|
+
end.to raise_error(LLM::Fleet::TokenError, /missing signed execution_contract/)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
it 'a tampered offering_id claim is a TokenError' do
|
|
291
|
+
claims = {
|
|
292
|
+
execution_contract: LLM::Fleet::Protocol::EXACT_EXECUTION_CONTRACT,
|
|
293
|
+
offering_id: 'local:other:other:inference:other'
|
|
294
|
+
}
|
|
295
|
+
expect do
|
|
296
|
+
LLM::Fleet::TokenValidator.validate_exact_execution_claims!(claims, exact_envelope_data)
|
|
297
|
+
end.to raise_error(LLM::Fleet::TokenError, /offering_id claim mismatch/)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
RSpec.shared_examples 'F4 — fencing (07 §3)' do
|
|
302
|
+
let(:fence_key) { INV::Identity::InstanceKey.new(provider_family: 'kit', instance_id: 'fence') }
|
|
303
|
+
let(:fence_coordinator) do
|
|
304
|
+
LLM::Inventory::ProbeCoordinator.new(instance_key: fence_key, enqueue: ->(**) { true })
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
it 'a superseded publisher token is fenced' do
|
|
308
|
+
registry.reset!
|
|
309
|
+
token1 = registry.claim_instance(instance_key: fence_key, callable: Object.new, probe_request_handle: fence_coordinator)
|
|
310
|
+
registry.claim_instance(instance_key: fence_key, callable: Object.new, probe_request_handle: fence_coordinator)
|
|
311
|
+
expect { registry.readiness_probe_started(instance_key: fence_key, publisher_token: token1) }
|
|
312
|
+
.to raise_error(INV::Errors::FencedPublisherError)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it 'a sequence at or below the claim baseline is rejected (StaleSequenceError)' do
|
|
316
|
+
first = registry.claim_instance(instance_key: fence_key, callable: Object.new, probe_request_handle: fence_coordinator)
|
|
317
|
+
probe = registry.readiness_probe_started(instance_key: fence_key, publisher_token: first)
|
|
318
|
+
expect do
|
|
319
|
+
registry.activate_instance_snapshot(publisher_token: first, instance_key: fence_key,
|
|
320
|
+
offerings: drafts, sequence: -1, probe_token: probe)
|
|
321
|
+
end.to raise_error(INV::Errors::StaleSequenceError)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
RSpec.shared_examples 'F5 — response envelope (06 E3/E4)' do
|
|
326
|
+
it 'carries the serialized Canonical::Response and never the thinking member' do
|
|
327
|
+
thinking_callable = Class.new do
|
|
328
|
+
def chat(_messages, model:, **_rest)
|
|
329
|
+
CAN::Response.build(
|
|
330
|
+
text: 'done', model: model, stop_reason: :end_turn,
|
|
331
|
+
thinking: CAN::Thinking.build(content: 'secret-reasoning')
|
|
332
|
+
)
|
|
333
|
+
end
|
|
334
|
+
end.new
|
|
335
|
+
thinking_key = INV::Identity::InstanceKey.new(provider_family: 'fake_llm', instance_id: 'thinking')
|
|
336
|
+
token = registry.claim_instance(instance_key: thinking_key, callable: thinking_callable,
|
|
337
|
+
probe_request_handle: LLM::Inventory::ProbeCoordinator.new(
|
|
338
|
+
instance_key: thinking_key, enqueue: ->(**) { true }
|
|
339
|
+
))
|
|
340
|
+
probe = registry.readiness_probe_started(instance_key: thinking_key, publisher_token: token)
|
|
341
|
+
thinking_lane = INV::Identity.compose_lane_id(
|
|
342
|
+
tier: :local, provider_family: thinking_key.provider_family, instance_id: thinking_key.instance_id,
|
|
343
|
+
type: LLM::Taxonomies.lane_type_for(operation: :chat), model: 'gemma4'
|
|
344
|
+
)
|
|
345
|
+
registry.activate_instance_snapshot(publisher_token: token, instance_key: thinking_key,
|
|
346
|
+
offerings: [
|
|
347
|
+
INV::OfferingDraft.new(
|
|
348
|
+
provider_native_key: 'gemma4', model: 'gemma4', tier: :local,
|
|
349
|
+
operation_evidence: operation_evidence_map,
|
|
350
|
+
context_evidence: unknown_value, max_output_evidence: unknown_value,
|
|
351
|
+
embedding_dimensions_evidence: unknown_value,
|
|
352
|
+
model_revision_evidence: unknown_value, tokenizer_evidence: unknown_value,
|
|
353
|
+
publication_source: :provider_catalog
|
|
354
|
+
)
|
|
355
|
+
], sequence: 0, probe_token: probe)
|
|
356
|
+
|
|
357
|
+
published_args = nil
|
|
358
|
+
response_double = instance_double(LLM::Transport::Messages::FleetResponse, publish: true)
|
|
359
|
+
allow(LLM::Transport::Messages::FleetResponse).to receive(:new) do |*args|
|
|
360
|
+
published_args = args
|
|
361
|
+
response_double
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
LLM::Fleet::ProviderResponder.call(
|
|
365
|
+
payload: exact_envelope_data.merge(provider_instance: 'thinking', offering_id: thinking_lane),
|
|
366
|
+
provider_family: exact_envelope_data[:provider],
|
|
367
|
+
registry: registry
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
expect(published_args.first[:response]).to include(text: 'done')
|
|
371
|
+
expect(published_args.first[:response]).not_to have_key(:thinking)
|
|
372
|
+
expect(response_double).to have_received(:publish)
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
RSpec.shared_examples 'F6 — contract errors (06 §5)' do
|
|
377
|
+
{
|
|
378
|
+
'a non-Hash wire message' => ->(data) { data.merge(params: { messages: ['nope'] }) },
|
|
379
|
+
'duplicate param spellings' => ->(data) { data.merge(params: { 'messages' => [], :messages => [] }) },
|
|
380
|
+
'an unknown operation' => ->(data) { data.merge(operation: 'teleport') }
|
|
381
|
+
}.each do |label, mutator|
|
|
382
|
+
it "#{label} is a Fleet::ContractError, never a silent substitute" do
|
|
383
|
+
expect { LLM::Fleet::WorkerExecution.call(envelope: mutator.call(exact_envelope_data), registry: registry) }
|
|
384
|
+
.to raise_error(LLM::Fleet::ContractError)
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
RSpec.shared_examples 'F7 — retryability (06 F6)' do
|
|
390
|
+
it 'contract, policy, and auth errors are never retryable' do
|
|
391
|
+
responder = LLM::Fleet::ProviderResponder
|
|
392
|
+
expect(responder.retryable_error?(LLM::Fleet::ContractError.new('x'))).to be(false)
|
|
393
|
+
expect(responder.retryable_error?(LLM::Fleet::WorkerExecution::PolicyError.new('x'))).to be(false)
|
|
394
|
+
expect(responder.retryable_error?(LLM::Fleet::TokenError.new('x'))).to be(false)
|
|
395
|
+
expect(responder.retryable_error?(INV::Errors::ExactOfferingMismatchError.new('x'))).to be(false)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
it 'transient kinds are retryable' do
|
|
399
|
+
responder = LLM::Fleet::ProviderResponder
|
|
400
|
+
expect(responder.retryable_error?(LLM::RateLimitError.new(nil, 'x'))).to be(true)
|
|
401
|
+
expect(responder.retryable_error?(LLM::OverloadedError.new(nil, 'x'))).to be(true)
|
|
402
|
+
expect(responder.retryable_error?(Timeout::Error.new('x'))).to be(true)
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
RSpec.shared_examples 'R1 — state machine (07 §3)' do
|
|
407
|
+
let(:r1_key) { INV::Identity::InstanceKey.new(provider_family: 'kit', instance_id: 'r1') }
|
|
408
|
+
let(:r1_coordinator) { LLM::Inventory::ProbeCoordinator.new(instance_key: r1_key, enqueue: ->(**) { true }) }
|
|
409
|
+
|
|
410
|
+
it 'activate without a claim is invalid' do
|
|
411
|
+
registry.reset!
|
|
412
|
+
expect do
|
|
413
|
+
registry.activate_instance_snapshot(publisher_token: 'forged', instance_key: r1_key,
|
|
414
|
+
offerings: drafts, sequence: 0)
|
|
415
|
+
end.to raise_error(ArgumentError)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
it 'a claim without activation stays initializing (no active instance)' do
|
|
419
|
+
registry.claim_instance(instance_key: r1_key, callable: Object.new, probe_request_handle: r1_coordinator)
|
|
420
|
+
expect(registry.snapshot.instance(instance_key: r1_key)).to be_nil
|
|
421
|
+
expect(registry.snapshot.publication_status(instance_key: r1_key).state).to eq(:initializing)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
RSpec.shared_examples 'R2 — identity (07 §1, 5-tuple law)' do
|
|
426
|
+
it 'composes the same 5 tuple deterministically from the same fields' do
|
|
427
|
+
lane = INV::Identity.compose_lane_id(
|
|
428
|
+
tier: :local, provider_family: key.provider_family, instance_id: key.instance_id,
|
|
429
|
+
type: LLM::Taxonomies.lane_type_for(operation: :chat), model: 'gemma4'
|
|
430
|
+
)
|
|
431
|
+
expect(lane).to eq(
|
|
432
|
+
INV::Identity.compose_lane_id(
|
|
433
|
+
tier: :local, provider_family: key.provider_family, instance_id: key.instance_id,
|
|
434
|
+
type: LLM::Taxonomies.lane_type_for(operation: :chat), model: 'gemma4'
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
expect(INV::Identity.validate_lane_id!(value: lane)).to eq(lane)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
it 'round-trips a colon-containing model through the bounded parse' do
|
|
441
|
+
lane = INV::Identity.compose_lane_id(
|
|
442
|
+
tier: :local, provider_family: key.provider_family, instance_id: key.instance_id,
|
|
443
|
+
type: :inference, model: 'model:tag:0'
|
|
444
|
+
)
|
|
445
|
+
expect(INV::Identity.parse_lane_id(lane).last).to eq('model:tag:0')
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
it 'rejects a value that is not a 5-tuple' do
|
|
449
|
+
expect do
|
|
450
|
+
INV::Identity.validate_lane_id!(value: 'not-a-five-tuple')
|
|
451
|
+
end.to raise_error(INV::Errors::ValidationError, /5 parts/)
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
RSpec.shared_examples 'R3 — snapshot law (07 §5)' do
|
|
456
|
+
it 'lookups return nil for absent scopes (no synthesized defaults)' do
|
|
457
|
+
absent = INV::Identity::InstanceKey.new(provider_family: 'vllm', instance_id: 'nowhere')
|
|
458
|
+
expect(registry.snapshot.instance(instance_key: absent)).to be_nil
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
it 'is generation-stable across later mutations' do
|
|
462
|
+
snapshot_before = registry.snapshot
|
|
463
|
+
before_record = snapshot_before.instance(instance_key: key)
|
|
464
|
+
expect(before_record).not_to be_nil
|
|
465
|
+
|
|
466
|
+
r3_key = INV::Identity::InstanceKey.new(provider_family: 'kit', instance_id: 'r3')
|
|
467
|
+
r3_coordinator = LLM::Inventory::ProbeCoordinator.new(instance_key: r3_key, enqueue: ->(**) { true })
|
|
468
|
+
registry.claim_instance(instance_key: r3_key, callable: Object.new, probe_request_handle: r3_coordinator)
|
|
469
|
+
expect(snapshot_before.generation).not_to eq(registry.snapshot.generation)
|
|
470
|
+
# The captured snapshot still serves its own generation's records.
|
|
471
|
+
expect(snapshot_before.instance(instance_key: key)).to eq(before_record)
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
RSpec.shared_examples 'R4 — weight law (07 §4)' do
|
|
476
|
+
it 'accepts zero (operator disable), rejects non-Integer, and requires base == product' do
|
|
477
|
+
schema = LLM::Inventory.const_get(:WeightSchema)
|
|
478
|
+
zero = { tier: 100, provider: 0, instance: 100, model_or_offering: 100 }
|
|
479
|
+
expect(schema.base_weight(zero)).to eq(0)
|
|
480
|
+
|
|
481
|
+
expect do
|
|
482
|
+
INV::RecordSupport.validated_weight_pair(
|
|
483
|
+
weight_inputs: { tier: '100', provider: 0, instance: 100, model_or_offering: 100 }, base_weight: 0
|
|
484
|
+
)
|
|
485
|
+
end.to raise_error(INV::Errors::ValidationError)
|
|
486
|
+
|
|
487
|
+
expect do
|
|
488
|
+
INV::RecordSupport.validated_weight_pair(
|
|
489
|
+
weight_inputs: zero, base_weight: 1
|
|
490
|
+
)
|
|
491
|
+
end.to raise_error(INV::Errors::ValidationError, /product/)
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
RSpec.shared_examples 'R5 — callable lifecycle (07 §6)' do
|
|
496
|
+
it 'acquires a lease exposing the exact captured callable and releases it' do
|
|
497
|
+
handle = registry.snapshot.instance(instance_key: key).callable_handle
|
|
498
|
+
lease = registry.acquire(callable_handle: handle)
|
|
499
|
+
expect(lease.callable).to be(callable)
|
|
500
|
+
lease.release
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
it 'an unknown handle is rejected' do
|
|
504
|
+
expect { registry.acquire(callable_handle: Object.new) }
|
|
505
|
+
.to raise_error(INV::Errors::UnknownCallableError)
|
|
506
|
+
end
|
|
507
|
+
end
|
|
@@ -78,7 +78,7 @@ RSpec.shared_examples 'an SSOT v3 provider adapter' do
|
|
|
78
78
|
lane_a = snapshot.lanes_for(instance_key: a[:key]).first
|
|
79
79
|
lane_b = snapshot.lanes_for(instance_key: b[:key]).first
|
|
80
80
|
expect(lane_a.lane_id).not_to eq(lane_b.lane_id)
|
|
81
|
-
expect(lane_a.
|
|
81
|
+
expect(lane_a.lane_id).to match(/\A[a-z_]+:[a-z_]+:[^:]+:[a-z_]+:.+\z/)
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
it 'exposes no selector-visible model or callable before startup readiness' do
|
|
@@ -107,22 +107,23 @@ RSpec.shared_examples 'an SSOT v3 provider adapter' do
|
|
|
107
107
|
it 'supports complete refresh, complete empty, and failed-refresh retention' do
|
|
108
108
|
context = bring_up(configs[0])
|
|
109
109
|
context[:publisher].replace_instance_snapshot(instance_id: context[:key].instance_id, publisher_token: context[:token], offerings: context[:drafts], sequence: 1)
|
|
110
|
-
expect(registry.snapshot.
|
|
110
|
+
expect(registry.snapshot.lanes_for(instance_key: context[:key]).size).to eq(1)
|
|
111
111
|
context[:publisher].replace_instance_snapshot(instance_id: context[:key].instance_id, publisher_token: context[:token], offerings: [], sequence: 2)
|
|
112
|
-
expect(registry.snapshot.
|
|
112
|
+
expect(registry.snapshot.lanes_for(instance_key: context[:key])).to be_empty
|
|
113
113
|
expect do
|
|
114
114
|
context[:publisher].replace_instance_snapshot(instance_id: context[:key].instance_id, publisher_token: context[:token], offerings: [], sequence: 2)
|
|
115
115
|
end.to raise_error(Legion::Extensions::Llm::Inventory::Errors::StaleSequenceError)
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
-
it '
|
|
118
|
+
it 'reflects a tier change in the lane id (tier is the 5 tuple first part)' do
|
|
119
119
|
context = bring_up(configs[0], tier: :local)
|
|
120
|
-
before_offering = registry.snapshot.offerings_for(instance_key: context[:key]).first.offering_id
|
|
121
120
|
before_lane = registry.snapshot.lanes_for(instance_key: context[:key]).first.lane_id
|
|
121
|
+
expect(before_lane).to start_with('local:')
|
|
122
122
|
frontier_drafts = ssot_harness.build_offering_drafts(instance_config: configs[0], callable: context[:callable], tier: :frontier)
|
|
123
123
|
context[:publisher].replace_instance_snapshot(instance_id: context[:key].instance_id, publisher_token: context[:token], offerings: frontier_drafts, sequence: 1)
|
|
124
|
-
|
|
125
|
-
expect(
|
|
124
|
+
after_lane = registry.snapshot.lanes_for(instance_key: context[:key]).first.lane_id
|
|
125
|
+
expect(after_lane).to start_with('frontier:')
|
|
126
|
+
expect(after_lane).not_to eq(before_lane)
|
|
126
127
|
end
|
|
127
128
|
|
|
128
129
|
it 'refuses recovery from a stale probe started before the failure' do
|
|
@@ -154,11 +155,11 @@ RSpec.shared_examples 'an SSOT v3 provider adapter' do
|
|
|
154
155
|
it 'executes an exact fleet request against only the captured callable' do
|
|
155
156
|
allow(Legion::Extensions::Llm::Fleet::WorkerExecution).to receive_messages(validate_identity!: true, validate_idempotency!: nil)
|
|
156
157
|
context = bring_up(configs[0])
|
|
157
|
-
|
|
158
|
+
lane = registry.snapshot.lanes_for(instance_key: context[:key]).first
|
|
158
159
|
envelope = {
|
|
159
160
|
execution_contract: Legion::Extensions::Llm::Fleet::Protocol::EXACT_EXECUTION_CONTRACT,
|
|
160
|
-
offering_id:
|
|
161
|
-
provider_instance: context[:key].instance_id, model:
|
|
161
|
+
offering_id: lane.lane_id, provider: ssot_harness.provider_family.to_s,
|
|
162
|
+
provider_instance: context[:key].instance_id, model: lane.model, operation: 'chat', params: { messages: [] }
|
|
162
163
|
}
|
|
163
164
|
Legion::Extensions::Llm::Fleet::WorkerExecution.call(envelope: envelope, registry: registry)
|
|
164
165
|
expect(ssot_harness.inference_call_count(callable: context[:callable])).to eq(1)
|
|
@@ -410,26 +410,25 @@ RSpec.describe Legion::Extensions::Llm::CredentialSources do
|
|
|
410
410
|
end
|
|
411
411
|
end
|
|
412
412
|
|
|
413
|
-
it '
|
|
414
|
-
|
|
415
|
-
|
|
413
|
+
it 'raises a configuration error for a missing file (O11 fail-closed)' do
|
|
414
|
+
expect { mod.send(:read_json, '/nonexistent/file.json') }
|
|
415
|
+
.to raise_error(Legion::Extensions::Llm::ConfigurationError, /credential file is missing/)
|
|
416
416
|
end
|
|
417
417
|
|
|
418
|
-
it '
|
|
418
|
+
it 'raises for invalid JSON (O11 fail-closed)' do
|
|
419
419
|
Dir.mktmpdir do |dir|
|
|
420
420
|
path = File.join(dir, 'bad.json')
|
|
421
421
|
File.write(path, 'not json{{{')
|
|
422
|
-
|
|
423
|
-
expect(result).to eq({})
|
|
422
|
+
expect { mod.send(:read_json, path) }.to raise_error(Legion::JSON::ParseError)
|
|
424
423
|
end
|
|
425
424
|
end
|
|
426
425
|
|
|
427
|
-
it '
|
|
426
|
+
it 'raises a configuration error for an empty file (O11 fail-closed)' do
|
|
428
427
|
Dir.mktmpdir do |dir|
|
|
429
428
|
path = File.join(dir, 'empty.json')
|
|
430
429
|
File.write(path, '')
|
|
431
|
-
|
|
432
|
-
|
|
430
|
+
expect { mod.send(:read_json, path) }
|
|
431
|
+
.to raise_error(Legion::Extensions::Llm::ConfigurationError, /credential file is empty/)
|
|
433
432
|
end
|
|
434
433
|
end
|
|
435
434
|
end
|
|
@@ -457,12 +456,12 @@ RSpec.describe Legion::Extensions::Llm::CredentialSources do
|
|
|
457
456
|
expect(mod.send(:token_valid?, token)).to be true
|
|
458
457
|
end
|
|
459
458
|
|
|
460
|
-
it 'returns
|
|
461
|
-
expect(mod.send(:token_valid?, 'not.a.jwt')).to be
|
|
459
|
+
it 'returns false on parse error (O11 fail-closed — no benefit of the doubt)' do
|
|
460
|
+
expect(mod.send(:token_valid?, 'not.a.jwt')).to be false
|
|
462
461
|
end
|
|
463
462
|
|
|
464
|
-
it 'returns
|
|
465
|
-
expect(mod.send(:token_valid?, nil)).to be
|
|
463
|
+
it 'returns false for nil token (O11 fail-closed)' do
|
|
464
|
+
expect(mod.send(:token_valid?, nil)).to be false
|
|
466
465
|
end
|
|
467
466
|
end
|
|
468
467
|
end
|
|
@@ -71,17 +71,7 @@ RSpec.describe Legion::Extensions::Llm::Error do
|
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
error = described_class.new('not supported')
|
|
77
|
-
|
|
78
|
-
expect(error.message).to eq('not supported')
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
it 'accepts the shared keyword initializer' do
|
|
82
|
-
error = described_class.new(provider: :openai, capability: :embed)
|
|
83
|
-
|
|
84
|
-
expect(error.message).to eq('Provider openai does not support embed')
|
|
85
|
-
end
|
|
74
|
+
it 'removes the UnsupportedCapabilityError compat alias (0.8.0 rip)' do
|
|
75
|
+
expect(Legion::Extensions::Llm.const_defined?(:UnsupportedCapabilityError, false)).to be(false)
|
|
86
76
|
end
|
|
87
77
|
end
|