lex-llm 0.7.6 → 0.8.4
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 +139 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
- data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
- data/lib/legion/extensions/llm/canonical/message.rb +53 -69
- data/lib/legion/extensions/llm/canonical/params.rb +58 -34
- data/lib/legion/extensions/llm/canonical/request.rb +68 -56
- data/lib/legion/extensions/llm/canonical/response.rb +62 -78
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
- data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
- 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 +7 -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 -156
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
- 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 +36 -100
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -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 +48 -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 +14 -26
- 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 +8 -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 +55 -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/config_spec.rb +222 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
- 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/client_translator_examples.rb +1 -2
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -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 +173 -95
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
- data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
- 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 +82 -47
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
- 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 +7 -3
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -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 +97 -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 -43
- 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
- metadata +13 -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 -310
- 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 -340
- 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
|
@@ -36,11 +36,21 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
|
|
|
36
36
|
|
|
37
37
|
def disconnect; end
|
|
38
38
|
|
|
39
|
-
def chat(messages
|
|
39
|
+
def chat(messages, model:, **rest)
|
|
40
40
|
@calls << { op: :chat, messages: messages, model: model, rest: rest }
|
|
41
41
|
{ content: 'exact-ok' }
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
def stream_chat(messages, model:, **rest)
|
|
45
|
+
@calls << { op: :stream_chat, messages: messages, model: model, rest: rest }
|
|
46
|
+
{ content: 'stream-exact-ok' }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def count_tokens(messages:, model:, **rest)
|
|
50
|
+
@calls << { op: :count_tokens, messages: messages, model: model, rest: rest }
|
|
51
|
+
1
|
|
52
|
+
end
|
|
53
|
+
|
|
44
54
|
def embed(text:, model:, **rest)
|
|
45
55
|
@calls << { op: :embed, text: text, model: model, rest: rest }
|
|
46
56
|
{ content: 'embed-ok' }
|
|
@@ -52,12 +62,17 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
|
|
|
52
62
|
|
|
53
63
|
before do
|
|
54
64
|
inventory::Registry.reset!
|
|
55
|
-
claim_and_activate(
|
|
65
|
+
claim_and_activate(
|
|
66
|
+
key: key, callable: recording_callable, coordinator: probe_coordinator(key),
|
|
67
|
+
supported: %i[chat stream_chat count_tokens]
|
|
68
|
+
)
|
|
56
69
|
allow(worker).to receive_messages(validate_identity!: true, validate_idempotency!: nil)
|
|
57
70
|
end
|
|
58
71
|
|
|
72
|
+
# The claim field keeps the name offering_id (D4); its value is the
|
|
73
|
+
# activated inference lane's 5 tuple.
|
|
59
74
|
def offering_id
|
|
60
|
-
Legion::Extensions::Llm::Inventory::
|
|
75
|
+
Legion::Extensions::Llm::Inventory::Registry.snapshot.lanes_for(instance_key: key).first.lane_id
|
|
61
76
|
end
|
|
62
77
|
|
|
63
78
|
def exact_envelope(**overrides)
|
|
@@ -79,10 +94,9 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
|
|
|
79
94
|
expect(handle_reference_count).to eq(0)
|
|
80
95
|
end
|
|
81
96
|
|
|
82
|
-
it 'requires
|
|
83
|
-
expect { worker.call(envelope: exact_envelope, registry:
|
|
84
|
-
.to raise_error(worker::PolicyError)
|
|
85
|
-
expect { worker.call(envelope: exact_envelope) }.to raise_error(worker::PolicyError)
|
|
97
|
+
it 'requires the registry — the provider-object topology is deleted (W1)' do
|
|
98
|
+
expect { worker.call(envelope: exact_envelope, registry: nil) }
|
|
99
|
+
.to raise_error(worker::PolicyError, /requires registry/)
|
|
86
100
|
end
|
|
87
101
|
|
|
88
102
|
it 'releases the lease even when the callable raises' do
|
|
@@ -92,7 +106,10 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
|
|
|
92
106
|
end
|
|
93
107
|
|
|
94
108
|
it 'rejects a mismatched offering_id, model, operation, and absent instance before invocation' do
|
|
95
|
-
|
|
109
|
+
foreign = Legion::Extensions::Llm::Inventory::Identity.compose_lane_id(
|
|
110
|
+
tier: :local, provider_family: 'vllm', instance_id: 'elsewhere', type: :inference, model: 'gemma4'
|
|
111
|
+
)
|
|
112
|
+
expect { worker.call(envelope: exact_envelope(offering_id: foreign), registry: inventory::Registry) }
|
|
96
113
|
.to raise_error(errors::ExactOfferingMismatchError)
|
|
97
114
|
expect { worker.call(envelope: exact_envelope(model: 'other'), registry: inventory::Registry) }
|
|
98
115
|
.to raise_error(errors::ExactOfferingMismatchError)
|
|
@@ -103,19 +120,19 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
|
|
|
103
120
|
expect(recording_callable.calls).to be_empty
|
|
104
121
|
end
|
|
105
122
|
|
|
106
|
-
it '
|
|
123
|
+
it 'raises ContractError on duplicate String/Symbol param spellings' do
|
|
107
124
|
expect { worker.call(envelope: exact_envelope(params: { 'messages' => [], :messages => [] }), registry: inventory::Registry) }
|
|
108
|
-
.to raise_error(
|
|
125
|
+
.to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /duplicate param spelling/)
|
|
109
126
|
end
|
|
110
127
|
|
|
111
|
-
it '
|
|
128
|
+
it 'raises ContractError on a params-supplied model (envelope model is the only model)' do
|
|
112
129
|
expect { worker.call(envelope: exact_envelope(params: { messages: [], model: 'x' }), registry: inventory::Registry) }
|
|
113
|
-
.to raise_error(
|
|
130
|
+
.to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /must not contain model/)
|
|
114
131
|
end
|
|
115
132
|
|
|
116
|
-
it '
|
|
133
|
+
it 'raises ContractError on a missing required operation param' do
|
|
117
134
|
expect { worker.call(envelope: exact_envelope(params: {}), registry: inventory::Registry) }
|
|
118
|
-
.to raise_error(
|
|
135
|
+
.to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /requires the messages param/)
|
|
119
136
|
end
|
|
120
137
|
|
|
121
138
|
it 'passes an explicit empty messages array and forwards unknown kwargs' do
|
|
@@ -124,36 +141,32 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
|
|
|
124
141
|
expect(call[:messages]).to eq([])
|
|
125
142
|
expect(call[:rest]).to eq(temperature: 0.2)
|
|
126
143
|
end
|
|
127
|
-
end
|
|
128
144
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
envelope = { provider: 'vllm', provider_instance: 'h200', model: 'gemma4', operation: 'chat', params: { messages: [] } }
|
|
147
|
-
expect { worker.call(envelope: envelope, registry: inventory::Registry) }.to raise_error(errors::AmbiguousLegacyOfferingError)
|
|
148
|
-
end
|
|
149
|
-
end
|
|
145
|
+
it 'rehydrates JSON-round-tripped messages before every message operation' do
|
|
146
|
+
raw_messages = [
|
|
147
|
+
{ role: 'system', content: 'follow the rules' },
|
|
148
|
+
{
|
|
149
|
+
role: 'assistant', content: '',
|
|
150
|
+
tool_calls: [{ id: 'call-1', name: 'lookup', arguments: { query: 'status' } }]
|
|
151
|
+
},
|
|
152
|
+
{ role: 'tool', tool_call_id: 'call-1', content: 'clean' },
|
|
153
|
+
{ role: 'user', content: 'continue' }
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
%w[chat stream_chat count_tokens].each do |operation|
|
|
157
|
+
wire_envelope = Legion::JSON.load(
|
|
158
|
+
Legion::JSON.dump(exact_envelope(operation: operation, params: { messages: raw_messages }))
|
|
159
|
+
)
|
|
160
|
+
worker.call(envelope: wire_envelope, registry: inventory::Registry)
|
|
161
|
+
end
|
|
150
162
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
163
|
+
expect(recording_callable.calls.map { |call| call[:op] }).to eq(%i[chat stream_chat count_tokens])
|
|
164
|
+
recording_callable.calls.each do |call|
|
|
165
|
+
expect(call[:messages]).to all(be_a(Legion::Extensions::Llm::Canonical::Message))
|
|
166
|
+
expect(call[:messages].map(&:role)).to eq(%i[system assistant tool user])
|
|
167
|
+
expect(call[:messages][1].tool_calls.first.name).to eq('lookup')
|
|
168
|
+
expect(call[:messages][2].tool_call_id).to eq('call-1')
|
|
169
|
+
end
|
|
157
170
|
end
|
|
158
171
|
end
|
|
159
172
|
|
|
@@ -167,13 +180,14 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ExactOffering do
|
|
|
167
180
|
expect { token_validator.validate_exact_execution_claims!(missing, envelope) }
|
|
168
181
|
.to raise_error(Legion::Extensions::Llm::Fleet::TokenError)
|
|
169
182
|
|
|
170
|
-
mismatched = { execution_contract: protocol::EXACT_EXECUTION_CONTRACT, offering_id:
|
|
183
|
+
mismatched = { execution_contract: protocol::EXACT_EXECUTION_CONTRACT, offering_id: 'local:other:other:inference:other' }
|
|
171
184
|
expect { token_validator.validate_exact_execution_claims!(mismatched, envelope) }
|
|
172
185
|
.to raise_error(Legion::Extensions::Llm::Fleet::TokenError)
|
|
173
186
|
end
|
|
174
187
|
|
|
175
|
-
it '
|
|
176
|
-
expect { token_validator.validate_exact_execution_claims!({}, { operation: 'chat' }) }
|
|
188
|
+
it 'verifies unconditionally — the marker-absence branch is deleted (S3)' do
|
|
189
|
+
expect { token_validator.validate_exact_execution_claims!({}, { operation: 'chat' }) }
|
|
190
|
+
.to raise_error(Legion::Extensions::Llm::Fleet::TokenError, /missing signed execution_contract/)
|
|
177
191
|
end
|
|
178
192
|
end
|
|
179
193
|
end
|
|
@@ -1,56 +1,58 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'spec_helper'
|
|
3
4
|
require 'legion/extensions/llm/fleet/provider_responder'
|
|
5
|
+
require 'support/ssot_registry_helpers'
|
|
4
6
|
|
|
5
7
|
RSpec.describe Legion::Extensions::Llm::Fleet::ProviderResponder do
|
|
8
|
+
include SsotRegistryHelpers
|
|
9
|
+
|
|
6
10
|
let(:protocol) { Legion::Extensions::Llm::Fleet::Protocol }
|
|
7
|
-
let(:
|
|
11
|
+
let(:key) { instance_key(family: 'ollama', instance: 'default') }
|
|
12
|
+
let(:callable) do
|
|
13
|
+
Class.new do
|
|
14
|
+
def chat(messages, model:, **_params)
|
|
15
|
+
Legion::Extensions::Llm::Canonical::Response.build(
|
|
16
|
+
text: "chat #{model} #{messages.first.text}",
|
|
17
|
+
model: model,
|
|
18
|
+
stop_reason: :end_turn,
|
|
19
|
+
usage: Legion::Extensions::Llm::Canonical::Usage.build(input_tokens: 1, output_tokens: 2)
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end.new
|
|
23
|
+
end
|
|
24
|
+
# Claim field keeps the name offering_id (D4); value = the lane 5 tuple.
|
|
25
|
+
let(:offering_id) do
|
|
26
|
+
Legion::Extensions::Llm::Inventory::Registry.snapshot.lanes_for(instance_key: key).first.lane_id
|
|
27
|
+
end
|
|
28
|
+
let(:payload) { payload_for(offering_id) }
|
|
29
|
+
|
|
30
|
+
def payload_for(offering_id)
|
|
8
31
|
{
|
|
9
32
|
request_id: 'req-1',
|
|
10
33
|
correlation_id: 'corr-1',
|
|
11
34
|
idempotency_key: 'idem-1',
|
|
12
|
-
operation:
|
|
13
|
-
provider:
|
|
14
|
-
provider_instance:
|
|
35
|
+
operation: 'chat',
|
|
36
|
+
provider: 'ollama',
|
|
37
|
+
provider_instance: 'default',
|
|
15
38
|
model: 'llama3',
|
|
16
|
-
params: { messages: [{ role: 'user', content: 'hello' }]
|
|
39
|
+
params: { messages: [{ role: 'user', content: 'hello' }] },
|
|
17
40
|
reply_to: 'reply.queue',
|
|
18
41
|
message_context: { conversation_id: 'conv-1' },
|
|
19
|
-
caller: { identity: 'user:
|
|
42
|
+
caller: { identity: 'user:test' },
|
|
20
43
|
trace_context: { trace_id: 'trace-1' },
|
|
21
44
|
signed_token: 'unsigned',
|
|
22
45
|
timeout_seconds: 30,
|
|
23
46
|
expires_at: (Time.now.utc + 30).iso8601,
|
|
24
|
-
protocol_version: protocol::VERSION
|
|
47
|
+
protocol_version: protocol::VERSION,
|
|
48
|
+
execution_contract: protocol::EXACT_EXECUTION_CONTRACT,
|
|
49
|
+
offering_id: offering_id
|
|
25
50
|
}
|
|
26
51
|
end
|
|
27
|
-
let(:provider_instances) do
|
|
28
|
-
{
|
|
29
|
-
default: {
|
|
30
|
-
base_url: 'http://localhost:11434',
|
|
31
|
-
fleet: { respond_to_requests: true }
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
end
|
|
35
|
-
let(:provider_class) do
|
|
36
|
-
Class.new do
|
|
37
|
-
attr_reader :settings
|
|
38
|
-
|
|
39
|
-
def initialize(settings)
|
|
40
|
-
@settings = settings
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def chat(messages:, model:, **params)
|
|
44
|
-
{
|
|
45
|
-
content: "chat #{model} #{messages.first[:content]}",
|
|
46
|
-
usage: { input_tokens: 1, output_tokens: 2 },
|
|
47
|
-
metadata: { temperature: params[:temperature], base_url: settings[:base_url] }
|
|
48
|
-
}
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
52
|
|
|
53
53
|
before do
|
|
54
|
+
Legion::Extensions::Llm::Inventory::Registry.reset!
|
|
55
|
+
claim_and_activate(key: key, callable: callable, coordinator: probe_coordinator(key), model: 'llama3')
|
|
54
56
|
Legion::Extensions::Llm::Fleet::WorkerExecution.reset_idempotency_cache!
|
|
55
57
|
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value).and_call_original
|
|
56
58
|
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
@@ -59,6 +61,14 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ProviderResponder do
|
|
|
59
61
|
.with(:fleet, :responder, :require_idempotency, default: nil).and_return(false)
|
|
60
62
|
end
|
|
61
63
|
|
|
64
|
+
it 'L6: the dead provider_class/provider_instances params are deleted (v3 dispatch never constructs a provider)' do
|
|
65
|
+
params = described_class.instance_method(:call).parameters
|
|
66
|
+
expect(params).not_to include(%i[key provider_class])
|
|
67
|
+
expect(params).not_to include(%i[key provider_instances])
|
|
68
|
+
expect { described_class.call(payload: payload, provider_family: 'ollama', provider_class: Object) }
|
|
69
|
+
.to raise_error(ArgumentError, /provider_class/)
|
|
70
|
+
end
|
|
71
|
+
|
|
62
72
|
it 'does not require the legion-llm namespace on responder nodes' do
|
|
63
73
|
hide_const('Legion::LLM') if defined?(Legion::LLM)
|
|
64
74
|
|
|
@@ -66,47 +76,55 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ProviderResponder do
|
|
|
66
76
|
expect(described_class).to respond_to(:call)
|
|
67
77
|
end
|
|
68
78
|
|
|
69
|
-
it '
|
|
79
|
+
it 'dispatches the exact request through the registry and publishes the serialized Canonical::Response (E3)' do
|
|
70
80
|
response_message = instance_double(Legion::Extensions::Llm::Transport::Messages::FleetResponse, publish: true)
|
|
71
|
-
|
|
81
|
+
published_args = nil
|
|
82
|
+
allow(Legion::Extensions::Llm::Transport::Messages::FleetResponse).to receive(:new) do |*args|
|
|
83
|
+
published_args = args
|
|
84
|
+
response_message
|
|
85
|
+
end
|
|
72
86
|
|
|
73
87
|
response = described_class.call(
|
|
74
88
|
payload: payload,
|
|
75
|
-
provider_family:
|
|
76
|
-
|
|
77
|
-
provider_instances: provider_instances
|
|
89
|
+
provider_family: 'ollama',
|
|
90
|
+
registry: Legion::Extensions::Llm::Inventory::Registry
|
|
78
91
|
)
|
|
79
92
|
|
|
80
|
-
expect(response).to
|
|
81
|
-
expect(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
metadata: { temperature: 0.1, base_url: 'http://localhost:11434' }
|
|
91
|
-
)
|
|
93
|
+
expect(response).to be_a(Legion::Extensions::Llm::Canonical::Response)
|
|
94
|
+
expect(response.text).to eq('chat llama3 hello')
|
|
95
|
+
expect(published_args.first).to include(
|
|
96
|
+
request_id: 'req-1',
|
|
97
|
+
correlation_id: 'corr-1',
|
|
98
|
+
provider: 'ollama',
|
|
99
|
+
provider_instance: 'default',
|
|
100
|
+
model: 'llama3',
|
|
101
|
+
execution_contract: protocol::EXACT_EXECUTION_CONTRACT,
|
|
102
|
+
offering_id: offering_id
|
|
92
103
|
)
|
|
104
|
+
published = published_args.first[:response]
|
|
105
|
+
expect(published).to include(text: 'chat llama3 hello', stop_reason: :end_turn)
|
|
106
|
+
# The envelope carries the serialized Canonical::Response; the Usage member
|
|
107
|
+
# is the canonical object (JSON encoding at publish serializes it per as_json).
|
|
108
|
+
expect(published[:usage]).to be_a(Legion::Extensions::Llm::Canonical::Usage)
|
|
109
|
+
expect(published[:usage].input_tokens).to eq(1)
|
|
110
|
+
expect(published[:usage].output_tokens).to eq(2)
|
|
93
111
|
expect(response_message).to have_received(:publish)
|
|
94
112
|
end
|
|
95
113
|
|
|
96
|
-
it 'rejects legacy fleet protocol fields before
|
|
114
|
+
it 'rejects legacy fleet protocol fields before execution (P4)' do
|
|
97
115
|
expect do
|
|
98
116
|
described_class.call(
|
|
99
117
|
payload: payload.merge(request_type: 'chat'),
|
|
100
|
-
provider_family:
|
|
101
|
-
|
|
102
|
-
provider_instances: provider_instances
|
|
118
|
+
provider_family: 'ollama',
|
|
119
|
+
registry: Legion::Extensions::Llm::Inventory::Registry
|
|
103
120
|
)
|
|
104
121
|
end.to raise_error(ArgumentError, /request_type/)
|
|
105
122
|
end
|
|
106
123
|
|
|
107
124
|
it 'reports whether a provider instance is enabled for fleet responses' do
|
|
108
|
-
expect(described_class.enabled_for?(
|
|
125
|
+
expect(described_class.enabled_for?(default: { fleet: { respond_to_requests: true } })).to be(true)
|
|
109
126
|
expect(described_class.enabled_for?(default: { fleet: { respond_to_requests: false } })).to be(false)
|
|
127
|
+
expect(described_class.enabled_for?(-> { { default: { fleet: { respond_to_requests: 'true' } } } })).to be(true)
|
|
110
128
|
end
|
|
111
129
|
|
|
112
130
|
it 'raises an actionable configuration error when fleet transport messages cannot load' do
|
|
@@ -118,62 +136,122 @@ RSpec.describe Legion::Extensions::Llm::Fleet::ProviderResponder do
|
|
|
118
136
|
end.to raise_error(described_class::ConfigurationError, /fleet responder transport unavailable/)
|
|
119
137
|
end
|
|
120
138
|
|
|
121
|
-
describe '
|
|
122
|
-
it '
|
|
123
|
-
|
|
124
|
-
|
|
139
|
+
describe 'P2 — exact-only execution contract' do
|
|
140
|
+
it 'rejects a missing marker (legacy v2 tolerance is deleted)' do
|
|
141
|
+
legacy = payload_for(offering_id).except(:execution_contract)
|
|
142
|
+
envelope = described_class.parse_payload(legacy)
|
|
143
|
+
expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }
|
|
144
|
+
.to raise_error(ArgumentError, /execution_contract is required/)
|
|
145
|
+
end
|
|
125
146
|
|
|
126
|
-
|
|
127
|
-
|
|
147
|
+
it 'rejects an unknown nonempty marker' do
|
|
148
|
+
envelope = described_class.parse_payload(payload.merge(execution_contract: 'made_up'))
|
|
149
|
+
expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }
|
|
150
|
+
.to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /execution_contract must be/)
|
|
128
151
|
end
|
|
129
152
|
|
|
130
|
-
it '
|
|
153
|
+
it 'requires the exact fields for the exact marker' do
|
|
154
|
+
envelope = described_class.parse_payload(payload.merge(offering_id: nil))
|
|
155
|
+
expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }
|
|
156
|
+
.to raise_error(ArgumentError, /offering_id is required/)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it 'rejects a false marker before dispatch' do
|
|
160
|
+
envelope = described_class.parse_payload(payload.merge(execution_contract: false))
|
|
161
|
+
expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }
|
|
162
|
+
.to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /execution_contract must be/)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'accepts a complete exact envelope' do
|
|
131
166
|
envelope = described_class.parse_payload(payload)
|
|
132
|
-
expect { described_class.check_envelope!(envelope, provider_family:
|
|
167
|
+
expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }.not_to raise_error
|
|
133
168
|
end
|
|
169
|
+
end
|
|
134
170
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
171
|
+
describe 'P3 — explicit protocol version' do
|
|
172
|
+
it 'rejects a missing protocol_version (no default fill)' do
|
|
173
|
+
envelope = described_class.parse_payload(payload.except(:protocol_version))
|
|
174
|
+
expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }
|
|
175
|
+
.to raise_error(ArgumentError, /protocol_version is required/)
|
|
138
176
|
end
|
|
139
177
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
described_class.call(
|
|
147
|
-
payload: payload.merge(marker),
|
|
148
|
-
provider_family: :ollama,
|
|
149
|
-
provider_class: provider_class,
|
|
150
|
-
provider_instances: provider_instances,
|
|
151
|
-
registry: registry
|
|
152
|
-
)
|
|
153
|
-
end.to raise_error(ArgumentError, /unknown execution_contract: false/)
|
|
178
|
+
it 'rejects a mismatched protocol_version' do
|
|
179
|
+
envelope = described_class.parse_payload(payload.merge(protocol_version: 2))
|
|
180
|
+
expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }
|
|
181
|
+
.to raise_error(ArgumentError, /protocol_version must be 3/)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
154
184
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
185
|
+
describe 'E1 — wire normalization' do
|
|
186
|
+
it 'raises on a wrong-shape payload (the silent {} fallback is deleted)' do
|
|
187
|
+
expect { described_class.parse_payload(42) }
|
|
188
|
+
.to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /expected Hash or String, got Integer/)
|
|
158
189
|
end
|
|
159
190
|
|
|
160
|
-
it '
|
|
161
|
-
envelope = described_class.parse_payload(
|
|
162
|
-
expect
|
|
163
|
-
|
|
191
|
+
it 'wraps Hash payloads through the single FleetEnvelope entry' do
|
|
192
|
+
envelope = described_class.parse_payload({ 'request_id' => 'r1', protocol_version: 3 })
|
|
193
|
+
expect(envelope).to be_a(Legion::Extensions::Llm::Fleet::FleetEnvelope)
|
|
194
|
+
expect(envelope.request_id).to eq('r1')
|
|
164
195
|
end
|
|
165
196
|
|
|
166
|
-
it '
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
197
|
+
it 'reads false values for both key spellings' do
|
|
198
|
+
symbol_envelope = Legion::Extensions::Llm::Fleet::FleetEnvelope.new(data: { execution_contract: false })
|
|
199
|
+
string_envelope = Legion::Extensions::Llm::Fleet::FleetEnvelope.new(data: { 'execution_contract' => false })
|
|
200
|
+
|
|
201
|
+
expect(symbol_envelope[:execution_contract]).to be(false)
|
|
202
|
+
expect(string_envelope[:execution_contract]).to be(false)
|
|
170
203
|
end
|
|
204
|
+
end
|
|
171
205
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
expect
|
|
176
|
-
expect(described_class.
|
|
206
|
+
describe 'F6 — retryability derived from the ProviderOutcome kind table' do
|
|
207
|
+
it 'never retries contract, policy, auth, or classification errors' do
|
|
208
|
+
expect(described_class.retryable_error?(Legion::Extensions::Llm::Fleet::ContractError.new('x'))).to be(false)
|
|
209
|
+
expect(described_class.retryable_error?(Legion::Extensions::Llm::Fleet::WorkerExecution::PolicyError.new('x'))).to be(false)
|
|
210
|
+
expect(described_class.retryable_error?(Legion::Extensions::Llm::Fleet::TokenError.new('x'))).to be(false)
|
|
211
|
+
expect(described_class.retryable_error?(Legion::Extensions::Llm::Inventory::Errors::ExactOfferingMismatchError.new('x'))).to be(false)
|
|
212
|
+
expect(described_class.retryable_error?(described_class::ConfigurationError.new('x'))).to be(false)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it 'retries transient provider kinds only' do
|
|
216
|
+
expect(described_class.retryable_error?(Legion::Extensions::Llm::RateLimitError.new(nil, 'x'))).to be(true)
|
|
217
|
+
expect(described_class.retryable_error?(Legion::Extensions::Llm::OverloadedError.new(nil, 'x'))).to be(true)
|
|
218
|
+
expect(described_class.retryable_error?(Timeout::Error.new('x'))).to be(true)
|
|
219
|
+
expect(described_class.retryable_error?(Legion::Extensions::Llm::UnauthorizedError.new(nil, 'x'))).to be(false)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
describe 'G5 — thinking never crosses the fleet' do
|
|
224
|
+
it 'excludes thinking from the response envelope exactly once at the builder' do
|
|
225
|
+
thinking_callable = Class.new do
|
|
226
|
+
def chat(_messages, model:, **)
|
|
227
|
+
Legion::Extensions::Llm::Canonical::Response.build(
|
|
228
|
+
text: 'done',
|
|
229
|
+
model: model,
|
|
230
|
+
stop_reason: :end_turn,
|
|
231
|
+
thinking: Legion::Extensions::Llm::Canonical::Thinking.build(content: 'secret-reasoning')
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
end.new
|
|
235
|
+
key2 = instance_key(family: 'ollama', instance: 'thinking')
|
|
236
|
+
Legion::Extensions::Llm::Inventory::Registry.reset!
|
|
237
|
+
claim_and_activate(key: key2, callable: thinking_callable, coordinator: probe_coordinator(key2), model: 'llama3')
|
|
238
|
+
thinking_offering = Legion::Extensions::Llm::Inventory::Registry
|
|
239
|
+
.snapshot.lanes_for(instance_key: key2).first.lane_id
|
|
240
|
+
|
|
241
|
+
response_message = instance_double(Legion::Extensions::Llm::Transport::Messages::FleetResponse, publish: true)
|
|
242
|
+
published_args = nil
|
|
243
|
+
allow(Legion::Extensions::Llm::Transport::Messages::FleetResponse).to receive(:new) do |*args|
|
|
244
|
+
published_args = args
|
|
245
|
+
response_message
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
described_class.call(
|
|
249
|
+
payload: payload_for(thinking_offering).merge(provider_instance: 'thinking'),
|
|
250
|
+
provider_family: 'ollama',
|
|
251
|
+
registry: Legion::Extensions::Llm::Inventory::Registry
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
expect(published_args.first[:response]).not_to have_key(:thinking)
|
|
177
255
|
end
|
|
178
256
|
end
|
|
179
257
|
end
|
|
@@ -17,10 +17,15 @@ RSpec.describe Legion::Extensions::Llm::Fleet::TokenValidator do
|
|
|
17
17
|
reply_to: 'reply.queue',
|
|
18
18
|
message_context: { conversation_id: 'conv-1' },
|
|
19
19
|
params: { messages: [{ role: 'user', content: 'hello' }] },
|
|
20
|
-
caller: { identity: 'user:
|
|
20
|
+
caller: { identity: 'user:primary' },
|
|
21
21
|
trace_context: { trace_id: 'trace-1' },
|
|
22
22
|
timeout_seconds: 30,
|
|
23
|
-
expires_at: expires_at
|
|
23
|
+
expires_at: expires_at,
|
|
24
|
+
# v3: exact claims are verified unconditionally (S3) — the envelope and
|
|
25
|
+
# the signed claims must both carry the exact marker + offering_id.
|
|
26
|
+
# The claim value is the lane's 5 tuple (D4).
|
|
27
|
+
execution_contract: Legion::Extensions::Llm::Fleet::Protocol::EXACT_EXECUTION_CONTRACT,
|
|
28
|
+
offering_id: 'local:ollama:default:inference:llama3'
|
|
24
29
|
}
|
|
25
30
|
end
|
|
26
31
|
let(:claims) do
|