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.
Files changed (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +53 -69
  8. data/lib/legion/extensions/llm/canonical/params.rb +58 -34
  9. data/lib/legion/extensions/llm/canonical/request.rb +68 -56
  10. data/lib/legion/extensions/llm/canonical/response.rb +62 -78
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
  13. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  17. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  18. data/lib/legion/extensions/llm/canonical.rb +7 -5
  19. data/lib/legion/extensions/llm/configuration.rb +40 -10
  20. data/lib/legion/extensions/llm/connection.rb +8 -30
  21. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  22. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  23. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  24. data/lib/legion/extensions/llm/error.rb +0 -14
  25. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  26. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  27. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  28. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  29. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
  30. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  31. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
  32. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  33. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  34. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  35. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  36. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  37. data/lib/legion/extensions/llm/inventory/records.rb +36 -100
  38. data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
  39. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  40. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
  41. data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
  42. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
  43. data/lib/legion/extensions/llm/provider.rb +192 -315
  44. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  45. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  46. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  47. data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
  48. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  49. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  50. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  51. data/lib/legion/extensions/llm/streaming.rb +50 -35
  52. data/lib/legion/extensions/llm/taxonomies.rb +14 -26
  53. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  54. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  55. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  56. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  57. data/lib/legion/extensions/llm/utils.rb +23 -5
  58. data/lib/legion/extensions/llm/version.rb +1 -1
  59. data/lib/legion/extensions/llm.rb +8 -98
  60. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  61. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  62. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  63. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  64. data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
  65. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  66. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  67. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  68. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
  69. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  70. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  71. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  72. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  73. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  74. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  75. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  76. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  79. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  80. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
  81. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
  82. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  83. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  84. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  85. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  86. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
  87. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  88. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
  89. data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
  90. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  91. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  92. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  93. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  94. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  95. data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
  96. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
  97. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  98. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  99. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
  100. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
  102. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  103. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  104. data/spec/legion/extensions/llm/provider_spec.rb +97 -78
  105. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  106. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  107. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  108. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
  109. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  110. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  111. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  112. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  113. data/spec/support/fake_llm_provider.rb +45 -39
  114. data/spec/support/fake_ssot_harness.rb +7 -2
  115. metadata +13 -54
  116. data/lib/legion/extensions/llm/agent.rb +0 -366
  117. data/lib/legion/extensions/llm/aliases.json +0 -436
  118. data/lib/legion/extensions/llm/aliases.rb +0 -67
  119. data/lib/legion/extensions/llm/attachment.rb +0 -229
  120. data/lib/legion/extensions/llm/chat.rb +0 -354
  121. data/lib/legion/extensions/llm/chunk.rb +0 -10
  122. data/lib/legion/extensions/llm/content.rb +0 -81
  123. data/lib/legion/extensions/llm/context.rb +0 -33
  124. data/lib/legion/extensions/llm/embedding.rb +0 -33
  125. data/lib/legion/extensions/llm/image.rb +0 -109
  126. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  127. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
  128. data/lib/legion/extensions/llm/message.rb +0 -118
  129. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  130. data/lib/legion/extensions/llm/model/info.rb +0 -286
  131. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  132. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  133. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  134. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  135. data/lib/legion/extensions/llm/model.rb +0 -11
  136. data/lib/legion/extensions/llm/models.json +0 -57313
  137. data/lib/legion/extensions/llm/models.rb +0 -530
  138. data/lib/legion/extensions/llm/models_schema.json +0 -168
  139. data/lib/legion/extensions/llm/moderation.rb +0 -60
  140. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  141. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  142. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  143. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  144. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  145. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  146. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  147. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  148. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  149. data/lib/legion/extensions/llm/thinking.rb +0 -53
  150. data/lib/legion/extensions/llm/tokens.rb +0 -51
  151. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  152. data/lib/legion/extensions/llm/transcription.rb +0 -39
  153. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  154. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  155. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  156. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  157. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  158. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  159. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
  160. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  161. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  162. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  163. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  164. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  165. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  166. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  167. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  168. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -1,80 +1,88 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'spec_helper'
3
4
  require 'legion/extensions/llm/fleet/worker_execution'
5
+ require 'support/ssot_registry_helpers'
4
6
 
5
7
  RSpec.describe Legion::Extensions::Llm::Fleet::WorkerExecution do
8
+ include SsotRegistryHelpers
9
+
10
+ let(:key) { instance_key(family: 'vllm', instance: 'h200') }
11
+ let(:callable) do
12
+ Class.new do
13
+ attr_reader :dispatched
14
+
15
+ def initialize
16
+ @dispatched = []
17
+ end
18
+
19
+ def chat(messages, model:, **rest)
20
+ @dispatched << { operation: :chat, model: model, first: messages.first, rest: rest }
21
+ Legion::Extensions::Llm::Canonical::Response.build(text: "#{model}:#{messages.first.text}", model: model)
22
+ end
23
+ end.new
24
+ end
6
25
  let(:envelope) do
7
26
  {
8
- operation: :chat,
9
- model: 'llama3',
27
+ request_id: 'req-1',
28
+ correlation_id: 'corr-1',
29
+ idempotency_key: 'idem-1',
30
+ operation: 'chat',
31
+ provider: 'vllm',
32
+ provider_instance: 'h200',
33
+ model: 'gemma4',
10
34
  params: { messages: [{ role: 'user', content: 'hello' }] },
35
+ reply_to: 'reply.1',
36
+ message_context: {},
37
+ caller: 'e2e',
38
+ trace_context: {},
11
39
  signed_token: 'unsigned',
12
- idempotency_key: 'idem-1'
40
+ timeout_seconds: 30,
41
+ expires_at: (Time.now + 120).utc.iso8601,
42
+ protocol_version: Legion::Extensions::Llm::Fleet::Protocol::VERSION,
43
+ execution_contract: Legion::Extensions::Llm::Fleet::Protocol::EXACT_EXECUTION_CONTRACT,
44
+ offering_id: Legion::Extensions::Llm::Inventory::Registry.snapshot
45
+ .lanes_for(instance_key: key).first.lane_id
13
46
  }
14
47
  end
15
- let(:provider) do
16
- Class.new do
17
- def chat(messages:, model:, **)
18
- { content: "#{model}:#{messages.first[:content]}" }
19
- end
20
- end.new
48
+
49
+ def activate!
50
+ claim_and_activate(key: key, callable: callable, coordinator: probe_coordinator(key), model: 'gemma4')
21
51
  end
22
52
 
23
53
  before do
54
+ Legion::Extensions::Llm::Inventory::Registry.reset!
55
+ activate!
24
56
  described_class.reset_idempotency_cache!
25
57
  allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value).and_call_original
26
58
  allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
27
59
  .with(:fleet, :auth, :require_signed_token, default: true).and_return(false)
28
- end
29
-
30
- it 'dispatches canonical chat operations directly to the local provider' do
31
60
  allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
32
61
  .with(:fleet, :responder, :require_idempotency, default: nil).and_return(false)
62
+ end
63
+
64
+ it 'executes the exact request against the captured registry callable (W1/W2)' do
65
+ response = described_class.call(envelope: envelope, registry: registry)
33
66
 
34
- expect(described_class.call(envelope: envelope, provider: provider)).to eq(content: 'llama3:hello')
67
+ expect(response).to be_a(Legion::Extensions::Llm::Canonical::Response)
68
+ expect(response.text).to eq('gemma4:hello')
69
+ expect(callable.dispatched.first[:first]).to be_a(Legion::Extensions::Llm::Canonical::Message)
35
70
  end
36
71
 
37
- it 'unpacks legacy nested options before dispatching to the local provider' do
38
- allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
39
- .with(:fleet, :responder, :require_idempotency, default: nil).and_return(false)
40
- captured_options = nil
41
- capture_provider = Class.new do
42
- define_method(:chat) do |messages:, model:, **options|
43
- captured_options = options
44
- { content: "#{model}:#{messages.first[:content]}" }
45
- end
46
- end.new
47
- legacy_envelope = envelope.merge(
48
- params: {
49
- messages: [{ role: 'user', content: 'hello' }],
50
- stream: false,
51
- tools: { current: { name: 'current' } },
52
- options: {
53
- 'stream' => true,
54
- 'system' => 'Use available tools.',
55
- 'tools' => { legacy: { name: 'legacy' } }
56
- }
57
- }
58
- )
59
-
60
- described_class.call(envelope: legacy_envelope, provider: capture_provider)
61
-
62
- expect(captured_options).to include(
63
- stream: false,
64
- system: 'Use available tools.',
65
- tools: { current: { name: 'current' } }
66
- )
67
- expect(captured_options).not_to have_key(:options)
72
+ it 'requires registry — the provider-object topology is deleted (W1)' do
73
+ expect { described_class.call(envelope: envelope, registry: nil) }
74
+ .to raise_error(described_class::PolicyError, /requires registry/)
75
+ expect(described_class.method(:call).parameters).to eq([%i[keyreq envelope], %i[keyreq registry]])
68
76
  end
69
77
 
70
- it 'rejects duplicate idempotency keys before executing the provider again' do
78
+ it 'rejects duplicate idempotency keys before executing the callable again (W7)' do
71
79
  allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
72
80
  .with(:fleet, :responder, :require_idempotency, default: nil).and_return(true)
73
81
 
74
- described_class.call(envelope: envelope, provider: provider)
82
+ described_class.call(envelope: envelope, registry: registry)
75
83
 
76
84
  expect do
77
- described_class.call(envelope: envelope, provider: provider)
85
+ described_class.call(envelope: envelope, registry: registry)
78
86
  end.to raise_error(described_class::PolicyError, /duplicate fleet idempotency key/)
79
87
  end
80
88
 
@@ -91,58 +99,69 @@ RSpec.describe Legion::Extensions::Llm::Fleet::WorkerExecution do
91
99
  end.to raise_error(described_class::PolicyError, /duplicate fleet idempotency key/)
92
100
  end
93
101
 
94
- it 'reserves token replay protection before provider dispatch and marks success after completion' do
102
+ it 'reserves token replay before dispatch and marks success after (S4)' do
95
103
  allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
96
104
  .with(:fleet, :auth, :require_signed_token, default: true).and_return(true)
97
- allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
98
- .with(:fleet, :responder, :require_idempotency, default: nil).and_return(false)
99
105
  allow(Legion::Extensions::Llm::Fleet::TokenValidator).to receive(:validate!).and_return({ jti: 'jti-1' })
100
106
  allow(Legion::Extensions::Llm::Fleet::TokenValidator).to receive(:mark_replay!)
101
107
 
102
- described_class.call(envelope: envelope, provider: provider)
108
+ described_class.call(envelope: envelope, registry: registry)
103
109
 
104
- expect(Legion::Extensions::Llm::Fleet::TokenValidator).to have_received(:validate!)
105
- .with(token: 'unsigned', envelope: envelope)
106
110
  expect(Legion::Extensions::Llm::Fleet::TokenValidator).to have_received(:mark_replay!).with('jti-1')
107
111
  end
108
112
 
109
- it 'releases reserved token replay state when provider dispatch fails' do
110
- failing_provider = Class.new do
111
- def chat(**)
112
- raise 'provider unavailable'
113
- end
114
- end.new
113
+ it 'releases reserved replay state when dispatch fails (S4)' do
114
+ failing_callable = Class.new { def chat(*, **) = raise('provider unavailable') }.new
115
+ key2 = instance_key(family: 'vllm', instance: 'h201')
116
+ claim_and_activate(key: key2, callable: failing_callable, coordinator: probe_coordinator(key2), model: 'gemma4')
117
+ failing_envelope = envelope.merge(provider_instance: 'h201',
118
+ offering_id: Legion::Extensions::Llm::Inventory::Registry.snapshot
119
+ .lanes_for(instance_key: key2).first.lane_id)
115
120
  allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
116
121
  .with(:fleet, :auth, :require_signed_token, default: true).and_return(true)
117
- allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
118
- .with(:fleet, :responder, :require_idempotency, default: nil).and_return(false)
119
122
  allow(Legion::Extensions::Llm::Fleet::TokenValidator).to receive(:validate!).and_return({ jti: 'jti-2' })
120
123
  allow(Legion::Extensions::Llm::Fleet::TokenValidator).to receive(:release_replay!)
121
124
 
122
125
  expect do
123
- described_class.call(envelope: envelope, provider: failing_provider)
126
+ described_class.call(envelope: failing_envelope, registry: registry)
124
127
  end.to raise_error(RuntimeError, /provider unavailable/)
125
128
 
126
129
  expect(Legion::Extensions::Llm::Fleet::TokenValidator).to have_received(:release_replay!).with('jti-2')
127
130
  end
128
131
 
129
- describe '.envelope_value' do
130
- it 'preserves false values for both key spellings' do
131
- expect(described_class.envelope_value({ execution_contract: false }, :execution_contract)).to be(false)
132
- expect(described_class.envelope_value({ 'execution_contract' => false }, :execution_contract)).to be(false)
132
+ describe 'W5/W4 — params and wire-message contract errors' do
133
+ it 'raises ContractError when params contain :model' do
134
+ bad = envelope.merge(params: { messages: [{ role: 'user', content: 'x' }], model: 'other' })
135
+ expect { described_class.call(envelope: bad, registry: registry) }
136
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /must not contain model/)
133
137
  end
134
138
 
135
- it 'prefers a present symbol key even when its value is false' do
136
- mixed = { execution_contract: false, 'execution_contract' => 'exact_offering_v1' }
139
+ it 'raises ContractError on a non-Hash wire message' do
140
+ bad = envelope.merge(params: { messages: ['not a message'] })
141
+ expect { described_class.call(envelope: bad, registry: registry) }
142
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /serialized Canonical::Message/)
143
+ end
137
144
 
138
- expect(described_class.envelope_value(mixed, :execution_contract)).to be(false)
145
+ it 'raises ContractError on a missing per-operation param' do
146
+ bad = envelope.merge(params: {})
147
+ expect { described_class.call(envelope: bad, registry: registry) }
148
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /requires the messages param/)
139
149
  end
140
150
 
141
- it 'preserves legacy absence and nil while returning the exact marker unchanged' do
142
- expect(described_class.envelope_value({}, :execution_contract)).to be_nil
143
- expect(described_class.envelope_value({ execution_contract: nil }, :execution_contract)).to be_nil
144
- expect(described_class.envelope_value({ execution_contract: 'exact_offering_v1' }, :execution_contract))
145
- .to eq('exact_offering_v1')
151
+ it 'raises ContractError on an unknown operation' do
152
+ bad = envelope.merge(operation: 'teleport')
153
+ expect { described_class.call(envelope: bad, registry: registry) }
154
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /unsupported exact operation/)
155
+ end
156
+ end
157
+
158
+ describe 'W6 — fail-closed policy' do
159
+ it 'raises when require_policy is enabled and no policy engine exists' do
160
+ allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
161
+ .with(:fleet, :responder, :require_policy, default: nil).and_return(true)
162
+
163
+ expect { described_class.call(envelope: envelope, registry: registry) }
164
+ .to raise_error(described_class::PolicyError, /no policy engine is configured/)
146
165
  end
147
166
  end
148
167
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
- # rubocop:disable RSpec/DescribeClass
5
+ # rubocop:disable-next RSpec/DescribeClass
6
6
  RSpec.describe 'LLM fleet message envelopes' do
7
7
  let(:protocol) { Legion::Extensions::Llm::Fleet::Protocol }
8
8
  let(:channel_class) do
@@ -20,6 +20,7 @@ RSpec.describe 'LLM fleet message envelopes' do
20
20
  routing_key: 'llm.fleet.inference.qwen.ctx8192',
21
21
  request_id: 'req-1',
22
22
  correlation_id: 'corr-1',
23
+ idempotency_key: 'idem-1',
23
24
  operation: :chat,
24
25
  provider: :vllm,
25
26
  provider_instance: :apollo,
@@ -33,21 +34,64 @@ RSpec.describe 'LLM fleet message envelopes' do
33
34
  timeout_seconds: 30,
34
35
  expires_at: '2026-05-06T12:00:30Z',
35
36
  protocol_version: protocol::VERSION,
36
- idempotency_key: 'idem-1'
37
+ execution_contract: protocol::EXACT_EXECUTION_CONTRACT,
38
+ offering_id: 'local:vllm:h200:inference:gemma4'
39
+ }
40
+ end
41
+
42
+ def valid_response_options
43
+ {
44
+ protocol_version: protocol::VERSION,
45
+ request_id: 'req-1',
46
+ correlation_id: 'corr-1',
47
+ idempotency_key: 'idem-1',
48
+ operation: :chat,
49
+ provider: :vllm,
50
+ provider_instance: :apollo,
51
+ model: 'qwen',
52
+ reply_to: 'llm.fleet.reply.node',
53
+ message_context: { conversation_id: 'conv-1' },
54
+ trace_context: { trace_id: 'trace-1' },
55
+ response: { text: 'hello', stop_reason: :end_turn, usage: { input_tokens: 1, output_tokens: 2 } },
56
+ execution_contract: protocol::EXACT_EXECUTION_CONTRACT,
57
+ offering_id: 'local:vllm:h200:inference:gemma4'
37
58
  }
38
59
  end
39
60
 
40
61
  describe Legion::Extensions::Llm::Fleet::Protocol do
41
- it 'defines fleet protocol v2 message types' do
42
- expect(described_class::VERSION).to eq(2)
62
+ it 'defines fleet protocol v3 with the one required-field list (P1/E2)' do
63
+ expect(described_class::VERSION).to eq(3)
43
64
  expect(described_class::REQUEST_TYPE).to eq('llm.fleet.request')
44
65
  expect(described_class::RESPONSE_TYPE).to eq('llm.fleet.response')
45
66
  expect(described_class::ERROR_TYPE).to eq('llm.fleet.error')
67
+ expect(described_class::REQUIRED_FIELDS).to include(
68
+ :request_id, :correlation_id, :idempotency_key, :operation, :provider, :provider_instance,
69
+ :model, :params, :reply_to, :message_context, :caller, :trace_context, :signed_token,
70
+ :timeout_seconds, :expires_at, :protocol_version, :execution_contract, :offering_id
71
+ )
72
+ expect(described_class::LEGACY_FIELDS).to eq(%i[schema_version request_type fleet_correlation_id])
73
+ end
74
+ end
75
+
76
+ describe Legion::Extensions::Llm::Fleet::FleetEnvelope do
77
+ let(:envelope_class) { described_class }
78
+
79
+ it 'wraps a Hash payload and passes an existing envelope through' do
80
+ expect(envelope_class.wrap(a: 1).to_h).to eq(a: 1)
81
+ envelope = envelope_class.new(data: { b: 2 })
82
+ expect(envelope_class.wrap(envelope)).to be(envelope)
83
+ end
84
+
85
+ it 'L12: a non-Hash payload is a typed ContractError at the wrap boundary' do
86
+ expect { envelope_class.wrap('not a hash') }
87
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /expected Hash, got String/)
88
+ expect { envelope_class.wrap(42) }
89
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /expected Hash, got Integer/)
46
90
  end
47
91
  end
48
92
 
49
93
  describe Legion::Extensions::Llm::Transport::Messages::FleetRequest do
50
- it 'builds a strict protocol v2 request envelope' do
94
+ it 'builds a strict protocol v3 request envelope' do
51
95
  message = described_class.new(**valid_request_options)
52
96
 
53
97
  expect(message.type).to eq(protocol::REQUEST_TYPE)
@@ -55,7 +99,7 @@ RSpec.describe 'LLM fleet message envelopes' do
55
99
  expect(message.reply_to).to eq('llm.fleet.reply.node')
56
100
  expect(message.correlation_id).to eq('corr-1')
57
101
  expect(message.message).to include(
58
- protocol_version: 2,
102
+ protocol_version: 3,
59
103
  request_id: 'req-1',
60
104
  correlation_id: 'corr-1',
61
105
  operation: :chat,
@@ -70,16 +114,15 @@ RSpec.describe 'LLM fleet message envelopes' do
70
114
  signed_token: 'signed.jwt',
71
115
  timeout_seconds: 30,
72
116
  expires_at: '2026-05-06T12:00:30Z',
73
- idempotency_key: 'idem-1'
117
+ idempotency_key: 'idem-1',
118
+ execution_contract: protocol::EXACT_EXECUTION_CONTRACT,
119
+ offering_id: 'local:vllm:h200:inference:gemma4'
74
120
  )
75
121
  expect(message.message).not_to include(:schema_version, :request_type, :fleet_correlation_id)
76
122
  end
77
123
 
78
- it 'requires every protocol v2 request field' do
79
- required = %i[
80
- request_id correlation_id operation provider provider_instance model params reply_to
81
- message_context caller trace_context signed_token timeout_seconds expires_at protocol_version idempotency_key
82
- ]
124
+ it 'requires every protocol v3 request field (one Protocol list, E2)' do
125
+ required = protocol::REQUIRED_FIELDS + %i[routing_key]
83
126
 
84
127
  required.each do |field|
85
128
  expect do
@@ -88,10 +131,16 @@ RSpec.describe 'LLM fleet message envelopes' do
88
131
  end
89
132
  end
90
133
 
91
- it 'rejects protocol versions other than v2' do
134
+ it 'rejects protocol versions other than v3' do
135
+ expect do
136
+ described_class.new(**valid_request_options, protocol_version: 2)
137
+ end.to raise_error(ArgumentError, /protocol_version must be 3/)
138
+ end
139
+
140
+ it 'rejects a missing protocol_version (P3 — no default fill)' do
92
141
  expect do
93
- described_class.new(**valid_request_options, protocol_version: 1)
94
- end.to raise_error(ArgumentError, /protocol_version/)
142
+ described_class.new(**valid_request_options.except(:protocol_version))
143
+ end.to raise_error(ArgumentError, /protocol_version is required/)
95
144
  end
96
145
 
97
146
  it 'rejects legacy fleet envelope fields' do
@@ -141,9 +190,9 @@ RSpec.describe 'LLM fleet message envelopes' do
141
190
  name: 'llm.fleet',
142
191
  channel: channel
143
192
  )
144
- allow(channel).to receive(:confirm_select)
145
193
  message = described_class.new(**valid_request_options)
146
194
 
195
+ allow(channel).to receive(:confirm_select)
147
196
  allow(Legion::Extensions::Llm::Transport::Exchanges::Fleet).to receive(:cached_instance).and_return(exchange)
148
197
  result = message.publish(publish_confirm_timeout_ms: 5000)
149
198
 
@@ -154,26 +203,14 @@ RSpec.describe 'LLM fleet message envelopes' do
154
203
  end
155
204
 
156
205
  describe Legion::Extensions::Llm::Transport::Messages::FleetResponse do
157
- it 'builds a protocol v2 correlated response envelope' do
158
- message = described_class.new(
159
- request_id: 'req-1',
160
- correlation_id: 'corr-1',
161
- idempotency_key: 'idem-1',
162
- operation: :chat,
163
- reply_to: 'llm.fleet.reply.node',
164
- provider: :vllm,
165
- provider_instance: :apollo,
166
- model: 'qwen',
167
- content: 'hello',
168
- message_context: { conversation_id: 'conv-1' },
169
- trace_context: { trace_id: 'trace-1' }
170
- )
206
+ it 'builds a protocol v3 correlated response envelope carrying the serialized Canonical::Response (E3)' do
207
+ message = described_class.new(**valid_response_options)
171
208
 
172
209
  expect(message.type).to eq(protocol::RESPONSE_TYPE)
173
210
  expect(message.routing_key).to eq('llm.fleet.reply.node')
174
211
  expect(message.correlation_id).to eq('corr-1')
175
212
  expect(message.message).to include(
176
- protocol_version: 2,
213
+ protocol_version: 3,
177
214
  request_id: 'req-1',
178
215
  correlation_id: 'corr-1',
179
216
  idempotency_key: 'idem-1',
@@ -182,62 +219,48 @@ RSpec.describe 'LLM fleet message envelopes' do
182
219
  provider: :vllm,
183
220
  provider_instance: :apollo,
184
221
  model: 'qwen',
185
- content: 'hello',
186
- message_context: { conversation_id: 'conv-1' },
187
- trace_context: { trace_id: 'trace-1' }
222
+ response: { text: 'hello', stop_reason: :end_turn, usage: { input_tokens: 1, output_tokens: 2 } },
223
+ execution_contract: protocol::EXACT_EXECUTION_CONTRACT,
224
+ offering_id: 'local:vllm:h200:inference:gemma4'
188
225
  )
189
- expect(message.message).not_to include(:schema_version)
226
+ expect(message.message).not_to include(:schema_version, :content, :usage, :finish_reason, :tool_calls, :instance)
190
227
  end
191
228
 
192
- it 'includes execution_contract and offering_id only for an exact request' do
193
- exact = described_class.new(
194
- request_id: 'req-1', correlation_id: 'corr-1', reply_to: 'llm.fleet.reply.node', content: 'hi',
195
- execution_contract: 'exact_offering_v1', offering_id: 'off:v1:abc'
196
- )
197
- expect(exact.message).to include(execution_contract: 'exact_offering_v1', offering_id: 'off:v1:abc')
198
-
199
- legacy = described_class.new(
200
- request_id: 'req-1', correlation_id: 'corr-1', reply_to: 'llm.fleet.reply.node', content: 'hi'
201
- )
202
- expect(legacy.message).not_to have_key(:execution_contract)
203
- expect(legacy.message).not_to have_key(:offering_id)
229
+ it 'requires the response payload and a protocol version' do
230
+ expect do
231
+ described_class.new(**valid_response_options.except(:response))
232
+ end.to raise_error(ArgumentError, /response is required/)
233
+ expect do
234
+ described_class.new(**valid_response_options.except(:protocol_version))
235
+ end.to raise_error(ArgumentError, /protocol_version is required/)
204
236
  end
205
237
 
206
- it 'rejects response protocol versions other than v2' do
238
+ it 'rejects response protocol versions other than v3' do
207
239
  expect do
208
- described_class.new(
209
- request_id: 'req-1',
210
- correlation_id: 'corr-1',
211
- reply_to: 'llm.fleet.reply.node',
212
- content: 'hello',
213
- protocol_version: 1
214
- )
215
- end.to raise_error(ArgumentError, /protocol_version/)
240
+ described_class.new(**valid_response_options, protocol_version: 2)
241
+ end.to raise_error(ArgumentError, /protocol_version must be 3/)
216
242
  end
217
243
 
218
- it 'does not expose thinking in caller-visible response payloads' do
219
- message = described_class.new(
220
- request_id: 'req-1',
221
- correlation_id: 'corr-1',
222
- reply_to: 'llm.fleet.reply.node',
223
- content: 'visible',
224
- thinking: 'hidden'
225
- )
244
+ it 'is a pass-through for the response payload — the G5 thinking exclusion happens exactly once at the responder builder' do
245
+ message = described_class.new(**valid_response_options, response: { text: 'visible', stop_reason: :end_turn })
226
246
 
227
- expect(message.message).to include(content: 'visible')
247
+ expect(message.message[:response]).to eq(text: 'visible', stop_reason: :end_turn)
228
248
  expect(message.message).not_to have_key(:thinking)
229
- expect(message.encode_message).not_to include('hidden', 'thinking')
249
+ expect(message.encode_message).not_to include('thinking')
250
+ end
251
+
252
+ it 'rejects legacy fleet envelope fields' do
253
+ %i[schema_version request_type fleet_correlation_id].each do |field|
254
+ expect do
255
+ described_class.new(**valid_response_options, field => 'legacy')
256
+ end.to raise_error(ArgumentError, /#{field}/)
257
+ end
230
258
  end
231
259
 
232
260
  it 'publishes replies through the AMQP default exchange without confirms or mandatory routing' do
233
261
  default_exchange = instance_double(Bunny::Exchange, publish: true)
234
262
  channel = instance_double(Bunny::Channel, default_exchange: default_exchange)
235
- message = described_class.new(
236
- request_id: 'req-1',
237
- correlation_id: 'corr-1',
238
- reply_to: 'llm.fleet.reply.node',
239
- content: 'hello'
240
- )
263
+ message = described_class.new(**valid_response_options)
241
264
 
242
265
  allow(message).to receive(:channel).and_return(channel)
243
266
  message.publish
@@ -257,12 +280,7 @@ RSpec.describe 'LLM fleet message envelopes' do
257
280
  channel = instance_double(channel_class, default_exchange: nil, on_return: nil)
258
281
  default_exchange = instance_double(Bunny::Exchange, publish: true, name: 'default', channel: channel)
259
282
  allow(channel).to receive(:default_exchange).and_return(default_exchange)
260
- message = described_class.new(
261
- request_id: 'req-1',
262
- correlation_id: 'corr-1',
263
- reply_to: 'llm.fleet.reply.node',
264
- content: 'hello'
265
- )
283
+ message = described_class.new(**valid_response_options)
266
284
 
267
285
  allow(message).to receive(:channel).and_return(channel)
268
286
  result = message.publish(return_result: true, headers: { 'x-custom' => 'yes' }, mandatory: true)
@@ -287,12 +305,7 @@ RSpec.describe 'LLM fleet message envelopes' do
287
305
  it 'returns a transport-style failure result for transient reply publish errors' do
288
306
  default_exchange = instance_double(Bunny::Exchange, name: 'default')
289
307
  channel = instance_double(Bunny::Channel, default_exchange: default_exchange)
290
- message = described_class.new(
291
- request_id: 'req-1',
292
- correlation_id: 'corr-1',
293
- reply_to: 'llm.fleet.reply.node',
294
- content: 'hello'
295
- )
308
+ message = described_class.new(**valid_response_options)
296
309
 
297
310
  allow(default_exchange).to receive(:publish).and_raise(IOError, 'socket closed')
298
311
  allow(message).to receive(:channel).and_return(channel)
@@ -310,27 +323,20 @@ RSpec.describe 'LLM fleet message envelopes' do
310
323
  end
311
324
 
312
325
  describe Legion::Extensions::Llm::Transport::Messages::FleetError do
313
- it 'builds a protocol v2 correlated error envelope' do
326
+ it 'builds a protocol v3 correlated error envelope (E6)' do
314
327
  message = described_class.new(
315
- request_id: 'req-1',
316
- correlation_id: 'corr-1',
317
- idempotency_key: 'idem-1',
318
- operation: :chat,
319
- reply_to: 'llm.fleet.reply.node',
320
- provider: :vllm,
321
- provider_instance: :apollo,
322
- model: 'qwen',
328
+ **valid_response_options.slice(:protocol_version, :request_id, :correlation_id, :idempotency_key, :operation,
329
+ :provider, :provider_instance, :model, :reply_to, :message_context,
330
+ :trace_context, :execution_contract, :offering_id),
323
331
  code: 'provider_failed',
324
332
  message: 'provider failed',
325
- retryable: true,
326
- message_context: { conversation_id: 'conv-1' },
327
- trace_context: { trace_id: 'trace-1' }
333
+ retryable: true
328
334
  )
329
335
 
330
336
  expect(message.type).to eq(protocol::ERROR_TYPE)
331
337
  expect(message.routing_key).to eq('llm.fleet.reply.node')
332
338
  expect(message.message).to include(
333
- protocol_version: 2,
339
+ protocol_version: 3,
334
340
  request_id: 'req-1',
335
341
  correlation_id: 'corr-1',
336
342
  idempotency_key: 'idem-1',
@@ -342,35 +348,28 @@ RSpec.describe 'LLM fleet message envelopes' do
342
348
  code: 'provider_failed',
343
349
  message: 'provider failed',
344
350
  retryable: true,
345
- message_context: { conversation_id: 'conv-1' },
346
- trace_context: { trace_id: 'trace-1' }
351
+ execution_contract: protocol::EXACT_EXECUTION_CONTRACT,
352
+ offering_id: 'local:vllm:h200:inference:gemma4'
347
353
  )
348
- expect(message.message).not_to include(:schema_version)
354
+ expect(message.message).not_to include(:schema_version, :instance)
349
355
  end
350
356
 
351
- it 'rejects error protocol versions other than v2' do
357
+ it 'rejects error protocol versions other than v3 and missing versions' do
358
+ base = valid_response_options.slice(:request_id, :correlation_id, :reply_to)
352
359
  expect do
353
- described_class.new(
354
- request_id: 'req-1',
355
- correlation_id: 'corr-1',
356
- reply_to: 'llm.fleet.reply.node',
357
- code: 'provider_failed',
358
- message: 'provider failed',
359
- protocol_version: 1
360
- )
361
- end.to raise_error(ArgumentError, /protocol_version/)
360
+ described_class.new(**base, protocol_version: 2, code: 'provider_failed', message: 'provider failed')
361
+ end.to raise_error(ArgumentError, /protocol_version must be 3/)
362
+ expect do
363
+ described_class.new(**base, code: 'provider_failed', message: 'provider failed')
364
+ end.to raise_error(ArgumentError, /protocol_version is required/)
362
365
  end
363
366
 
364
367
  it 'publishes errors through the AMQP default exchange without confirms or mandatory routing' do
365
368
  default_exchange = instance_double(Bunny::Exchange, publish: true)
366
369
  channel = instance_double(Bunny::Channel, default_exchange: default_exchange)
367
- message = described_class.new(
368
- request_id: 'req-1',
369
- correlation_id: 'corr-1',
370
- reply_to: 'llm.fleet.reply.node',
371
- code: 'provider_failed',
372
- message: 'provider failed'
373
- )
370
+ message = described_class.new(**valid_response_options.slice(:protocol_version, :request_id, :correlation_id,
371
+ :reply_to),
372
+ code: 'provider_failed', message: 'provider failed')
374
373
 
375
374
  allow(message).to receive(:channel).and_return(channel)
376
375
  message.publish
@@ -390,13 +389,9 @@ RSpec.describe 'LLM fleet message envelopes' do
390
389
  channel = instance_double(channel_class, default_exchange: nil, on_return: nil)
391
390
  default_exchange = instance_double(Bunny::Exchange, publish: true, name: 'default', channel: channel)
392
391
  allow(channel).to receive(:default_exchange).and_return(default_exchange)
393
- message = described_class.new(
394
- request_id: 'req-1',
395
- correlation_id: 'corr-1',
396
- reply_to: 'llm.fleet.reply.node',
397
- code: 'provider_failed',
398
- message: 'provider failed'
399
- )
392
+ message = described_class.new(**valid_response_options.slice(:protocol_version, :request_id, :correlation_id,
393
+ :reply_to),
394
+ code: 'provider_failed', message: 'provider failed')
400
395
 
401
396
  allow(message).to receive(:channel).and_return(channel)
402
397
  result = message.publish(return_result: true, headers: { 'x-custom' => 'yes' }, mandatory: true)
@@ -413,4 +408,3 @@ RSpec.describe 'LLM fleet message envelopes' do
413
408
  end
414
409
  end
415
410
  end
416
- # rubocop:enable RSpec/DescribeClass
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
- # rubocop:disable RSpec/DescribeClass
5
+ # rubocop:disable-next RSpec/DescribeClass
6
6
  RSpec.describe 'lex-llm.gemspec' do
7
7
  subject(:gemspec) { Gem::Specification.load(File.expand_path('../../../../lex-llm.gemspec', __dir__)) }
8
8
 
@@ -22,4 +22,3 @@ RSpec.describe 'lex-llm.gemspec' do
22
22
  expect(gemspec.name).to eq('lex-llm')
23
23
  end
24
24
  end
25
- # rubocop:enable RSpec/DescribeClass