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.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -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 +106 -92
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +52 -67
  8. data/lib/legion/extensions/llm/canonical/params.rb +59 -33
  9. data/lib/legion/extensions/llm/canonical/request.rb +67 -54
  10. data/lib/legion/extensions/llm/canonical/response.rb +61 -76
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
  13. data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
  14. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  15. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  16. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  17. data/lib/legion/extensions/llm/canonical.rb +6 -5
  18. data/lib/legion/extensions/llm/configuration.rb +40 -10
  19. data/lib/legion/extensions/llm/connection.rb +8 -30
  20. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  21. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  22. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  23. data/lib/legion/extensions/llm/error.rb +0 -14
  24. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  25. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  26. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  27. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  28. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
  29. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  30. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
  31. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  32. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  33. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  34. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  35. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  36. data/lib/legion/extensions/llm/inventory/records.rb +82 -99
  37. data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
  38. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  39. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
  40. data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
  41. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
  42. data/lib/legion/extensions/llm/provider.rb +192 -315
  43. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  44. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  45. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  46. data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
  47. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  48. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  49. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  50. data/lib/legion/extensions/llm/streaming.rb +50 -35
  51. data/lib/legion/extensions/llm/taxonomies.rb +29 -17
  52. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  53. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  54. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  55. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  56. data/lib/legion/extensions/llm/utils.rb +23 -5
  57. data/lib/legion/extensions/llm/version.rb +1 -1
  58. data/lib/legion/extensions/llm.rb +10 -98
  59. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  60. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  61. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  62. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  63. data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
  64. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  65. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  66. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
  67. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  68. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  69. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  70. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  71. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  72. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  73. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  74. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  75. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
  76. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
  77. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  78. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  79. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  80. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  81. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
  82. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  83. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
  84. data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
  85. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  86. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  87. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  88. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  89. data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
  90. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
  91. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  92. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  93. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
  94. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
  95. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
  96. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  97. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  98. data/spec/legion/extensions/llm/provider_spec.rb +98 -78
  99. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  100. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  101. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  102. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
  103. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  104. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  105. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  106. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  107. data/spec/support/fake_llm_provider.rb +45 -39
  108. data/spec/support/fake_ssot_harness.rb +7 -2
  109. data/spec/support/ssot_registry_helpers.rb +3 -2
  110. metadata +15 -54
  111. data/lib/legion/extensions/llm/agent.rb +0 -366
  112. data/lib/legion/extensions/llm/aliases.json +0 -436
  113. data/lib/legion/extensions/llm/aliases.rb +0 -67
  114. data/lib/legion/extensions/llm/attachment.rb +0 -229
  115. data/lib/legion/extensions/llm/chat.rb +0 -354
  116. data/lib/legion/extensions/llm/chunk.rb +0 -10
  117. data/lib/legion/extensions/llm/content.rb +0 -81
  118. data/lib/legion/extensions/llm/context.rb +0 -33
  119. data/lib/legion/extensions/llm/embedding.rb +0 -33
  120. data/lib/legion/extensions/llm/image.rb +0 -109
  121. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  122. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
  123. data/lib/legion/extensions/llm/message.rb +0 -118
  124. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  125. data/lib/legion/extensions/llm/model/info.rb +0 -286
  126. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  127. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  128. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  129. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  130. data/lib/legion/extensions/llm/model.rb +0 -11
  131. data/lib/legion/extensions/llm/models.json +0 -57313
  132. data/lib/legion/extensions/llm/models.rb +0 -530
  133. data/lib/legion/extensions/llm/models_schema.json +0 -168
  134. data/lib/legion/extensions/llm/moderation.rb +0 -60
  135. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  136. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  137. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  138. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  139. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  140. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  141. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  142. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  143. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  144. data/lib/legion/extensions/llm/thinking.rb +0 -53
  145. data/lib/legion/extensions/llm/tokens.rb +0 -51
  146. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  147. data/lib/legion/extensions/llm/transcription.rb +0 -39
  148. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  149. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  150. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  151. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  152. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  153. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  154. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
  155. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  156. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  157. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  158. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  159. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  160. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  161. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  162. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  163. 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,38 +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
131
+
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/)
137
+ end
138
+
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
144
+
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/)
149
+ end
150
+
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/)
165
+ end
166
+ end
128
167
  end
@@ -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)
@@ -47,7 +47,7 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Boot do
47
47
  it 'exposes every require-order constant' do
48
48
  %i[
49
49
  Errors ImmutableValue Identity Evidence CallableHandle DispatchLease ProbeToken ProbeRequest
50
- ProbeCoordinator PublisherToken OfferingDraft OfferingRecord LaneRecord AvailabilityFact
50
+ ProbeCoordinator PublisherToken OfferingDraft LaneRecord AvailabilityFact
51
51
  ReadinessResult InstanceRecord PublicationStatus MutationResult Snapshot Registry Publisher
52
52
  ].each { |const| expect(inventory.const_defined?(const)).to be(true), "missing Inventory::#{const}" }
53
53
  %i[AttemptTargetKey QuotaDomainKey Exclusion Selection Rejection BodyModelHintDecision ProviderOutcome].each do |const|
@@ -69,9 +69,9 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Boot do
69
69
  expect(Thread.list.size).to eq(baseline)
70
70
  end
71
71
 
72
- it 'retains legacy Types aliases and adds no new SSOT alias' do
73
- expect(Legion::Extensions::Llm::Types.const_defined?(:ModelOffering, false)).to be(true)
74
- expect(Legion::Extensions::Llm::Types.const_defined?(:InstanceKey, false)).to be(false)
72
+ it 'removes the legacy Types aliases (0.8.0 rip)' do
73
+ expect(Legion::Extensions::Llm.const_defined?(:Types, false)).to be(false)
74
+ expect(Legion::Extensions::Llm::Inventory.const_defined?(:InstanceKey, false)).to be(false)
75
75
  end
76
76
 
77
77
  it 'preserves the optional transport message registration (autoload or loaded)' do