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
@@ -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:, model:, **rest)
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(key: key, callable: recording_callable, coordinator: probe_coordinator(key))
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::Identity.offering_id(instance_key: key, provider_native_key: 'gemma4')
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 exactly one of registry or provider' do
83
- expect { worker.call(envelope: exact_envelope, registry: inventory::Registry, provider: Object.new) }
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
- expect { worker.call(envelope: exact_envelope(offering_id: "off:v1:#{'0' * 64}"), registry: inventory::Registry) }
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 'rejects duplicate String/Symbol param spellings' do
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(errors::ExactOfferingMismatchError)
125
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /duplicate param spelling/)
109
126
  end
110
127
 
111
- it 'rejects a params-supplied model (envelope model is the only model)' do
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(errors::ExactOfferingMismatchError)
130
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /must not contain model/)
114
131
  end
115
132
 
116
- it 'rejects a missing required operation param' do
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(errors::ExactOfferingMismatchError)
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
- describe 'registry-backed legacy v2 resolution' do
130
- def activate_two_offerings
131
- registry = Legion::Extensions::Llm::Inventory::Registry
132
- registry.reset!
133
- token = registry.claim_instance(instance_key: key, callable: recording_callable, probe_request_handle: probe_coordinator(key))
134
- probe = registry.readiness_probe_started(instance_key: key, publisher_token: token)
135
- two = drafts(native: 'a', model: 'gemma4') + drafts(native: 'b', model: 'gemma4')
136
- registry.activate_instance_snapshot(publisher_token: token, instance_key: key, offerings: two, sequence: 0, probe_token: probe)
137
- end
138
-
139
- it 'refuses zero matches' do
140
- envelope = { provider: 'vllm', provider_instance: 'h200', model: 'nope', operation: 'chat', params: { messages: [] } }
141
- expect { worker.call(envelope: envelope, registry: inventory::Registry) }.to raise_error(errors::ExactOfferingMismatchError)
142
- end
143
-
144
- it 'refuses multiple matches' do
145
- activate_two_offerings
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
- describe 'legacy provider path still operates' do
152
- it 'dispatches through the provider object without a registry' do
153
- provider = recording_callable
154
- envelope = { operation: 'chat', model: 'gemma4', params: { messages: [] } }
155
- worker.call(envelope: envelope, provider: provider)
156
- expect(recording_callable.calls.map { |c| c[:op] }).to eq([:chat])
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: "off:v1:#{'0' * 64}" }
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 'is a no-op for a legacy v2 envelope without the marker' do
176
- expect { token_validator.validate_exact_execution_claims!({}, { operation: 'chat' }) }.not_to raise_error
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(:payload) do
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: :chat,
13
- provider: :ollama,
14
- provider_instance: :default,
35
+ operation: 'chat',
36
+ provider: 'ollama',
37
+ provider_instance: 'default',
15
38
  model: 'llama3',
16
- params: { messages: [{ role: 'user', content: 'hello' }], temperature: 0.1 },
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:matt' },
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
25
- }
26
- end
27
- let(:provider_instances) do
28
- {
29
- default: {
30
- base_url: 'http://localhost:11434',
31
- fleet: { respond_to_requests: true }
32
- }
47
+ protocol_version: protocol::VERSION,
48
+ execution_contract: protocol::EXACT_EXECUTION_CONTRACT,
49
+ offering_id: offering_id
33
50
  }
34
51
  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 'builds a provider from the requested instance, dispatches the operation, and publishes a fleet response' do
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
- allow(Legion::Extensions::Llm::Transport::Messages::FleetResponse).to receive(:new).and_return(response_message)
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: :ollama,
76
- provider_class: provider_class,
77
- provider_instances: provider_instances
89
+ provider_family: 'ollama',
90
+ registry: Legion::Extensions::Llm::Inventory::Registry
78
91
  )
79
92
 
80
- expect(response).to include(content: 'chat llama3 hello')
81
- expect(Legion::Extensions::Llm::Transport::Messages::FleetResponse).to have_received(:new).with(
82
- hash_including(
83
- request_id: 'req-1',
84
- correlation_id: 'corr-1',
85
- provider: :ollama,
86
- provider_instance: :default,
87
- model: 'llama3',
88
- content: 'chat llama3 hello',
89
- usage: { input_tokens: 1, output_tokens: 2 },
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 provider execution' do
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: :ollama,
101
- provider_class: provider_class,
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?(provider_instances)).to be(true)
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,29 +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 'execution_contract marker validation' do
122
- it 'accepts a legacy v2 envelope with no marker' do
123
- envelope = described_class.parse_payload(payload)
124
- expect { described_class.check_envelope!(envelope, provider_family: :ollama) }.not_to raise_error
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/)
125
145
  end
126
146
 
127
147
  it 'rejects an unknown nonempty marker' do
128
148
  envelope = described_class.parse_payload(payload.merge(execution_contract: 'made_up'))
129
- expect { described_class.check_envelope!(envelope, provider_family: :ollama) }
130
- .to raise_error(ArgumentError, /unknown execution_contract/)
149
+ expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }
150
+ .to raise_error(Legion::Extensions::Llm::Fleet::ContractError, /execution_contract must be/)
131
151
  end
132
152
 
133
- it 'requires offering_id for the exact marker' do
134
- envelope = described_class.parse_payload(payload.merge(execution_contract: 'exact_offering_v1'))
135
- expect { described_class.check_envelope!(envelope, provider_family: :ollama) }
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') }
136
156
  .to raise_error(ArgumentError, /offering_id is required/)
137
157
  end
138
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
+
139
165
  it 'accepts a complete exact envelope' do
140
- exact = payload.merge(execution_contract: 'exact_offering_v1', offering_id: 'off:v1:abc')
141
- envelope = described_class.parse_payload(exact)
142
- expect { described_class.check_envelope!(envelope, provider_family: :ollama) }.not_to raise_error
143
- expect(described_class.exact?(envelope)).to be(true)
166
+ envelope = described_class.parse_payload(payload)
167
+ expect { described_class.check_envelope!(envelope, provider_family: 'ollama') }.not_to raise_error
168
+ end
169
+ end
170
+
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/)
176
+ end
177
+
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
184
+
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/)
189
+ end
190
+
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')
195
+ end
196
+
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)
203
+ end
204
+ end
205
+
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)
144
255
  end
145
256
  end
146
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:matt' },
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